Merge branch 'v2-parity-port': V2-idiomatic refactor of parity ports

Three commits route lifecycle telemetry through the V2 reducer rather than
synthesising events from Program.cs. Also wires the pre-existing LogReplay
infrastructure to the CLI.

- a63512d1 feat(agent-v2): post SessionStarted signal after register-session
- 5b7f424d refactor(agent-v2): whiteglove_resumed / system_reboot_detected /
           admin preemption flow through reducer effects
- f149eef3 feat(agent-v2): wire --replay-log-dir CLI flag to ImeLogTracker
           SimulationMode

Plus build-counter bump. DecisionSignalKind.AdminPreemptionDetected and
SessionOutcome.AdminPreempted are new additions; no breaking-schema concerns
because UnknownFallbackEnumConverter is already registered for both enums.

Tests: DecisionCore 95 (+7), V2 Core 489 (+8), V1 Core 507, Backend 708.
This commit is contained in:
Oliver Kieselbach
2026-04-22 17:02:01 +02:00
30 changed files with 2711 additions and 313 deletions
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Xunit;
@@ -9,8 +10,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
{
/// <summary>
/// Tests for <see cref="RemoteConfigMerger"/>. This merger is the single source of
/// Legacy-parity: every field delivered by the backend that has a live V2 consumer must
/// land on <see cref="AgentConfiguration"/> after <c>FetchConfigAsync</c>.
/// V1-parity: every field delivered by the backend that has a live V2 consumer must land
/// on <see cref="AgentConfiguration"/> after <c>FetchConfigAsync</c>.
/// <para>
/// V1 parity: remote values OVERWRITE the runtime configuration unconditionally. CLI
/// flags only seed the initial <see cref="AgentConfiguration"/> and yield to tenant
/// policy once Merge runs. This is asserted by a regression test below.
/// </para>
/// </summary>
public sealed class RemoteConfigMergerTests
{
@@ -46,6 +52,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
LogLevel = "Debug",
SendTraceEvents = false,
UnrestrictedMode = true,
MaxAuthFailures = 7,
AuthFailureTimeoutMinutes = 15,
EnableImeMatchLog = true,
Collectors = new CollectorConfiguration
{
AgentMaxLifetimeMinutes = 120,
@@ -56,12 +65,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
// ============================================================= Happy path
[Fact]
public void Merge_applies_all_tenant_fields_when_no_cli_overrides()
public void Merge_applies_all_tenant_fields()
{
var agent = NewAgentConfig();
var remote = FullRemote();
RemoteConfigMerger.Merge(agent, remote, Array.Empty<string>());
var result = RemoteConfigMerger.Merge(agent, remote);
Assert.False(agent.SelfDestructOnComplete);
Assert.True(agent.KeepLogFile);
@@ -85,40 +94,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(250, agent.MaxBatchSize);
Assert.Equal(120, agent.AgentMaxLifetimeMinutes);
Assert.Equal(45, agent.HelloWaitTimeoutSeconds);
Assert.Equal(7, agent.MaxAuthFailures);
Assert.Equal(15, agent.AuthFailureTimeoutMinutes);
Assert.NotNull(result);
Assert.True(result.UnrestrictedModeChanged);
Assert.False(result.OldUnrestrictedMode);
Assert.True(result.NewUnrestrictedMode);
Assert.True(result.LogLevelChanged);
Assert.Equal(AgentLogLevel.Info, result.OldLogLevel);
Assert.Equal(AgentLogLevel.Debug, result.NewLogLevel);
}
// ============================================================= CLI override wins
// ============================================================= Remote-wins-always (V1 parity)
[Fact]
public void Merge_preserves_no_cleanup_cli_flag_over_remote_true()
public void Merge_remote_wins_over_prior_agent_self_destruct()
{
// BuildAgentConfiguration may have set SelfDestructOnComplete from the --no-cleanup CLI
// flag. Once remote config arrives it takes over — V1 behaviour (operator cannot
// override tenant policy from the command line).
var agent = NewAgentConfig();
agent.SelfDestructOnComplete = false; // as set by BuildAgentConfiguration when CLI had --no-cleanup
agent.SelfDestructOnComplete = false;
var remote = FullRemote();
remote.SelfDestructOnComplete = true; // tenant enabled cleanup, dev disabled locally
remote.SelfDestructOnComplete = true;
RemoteConfigMerger.Merge(agent, remote, new[] { "--no-cleanup" });
RemoteConfigMerger.Merge(agent, remote);
Assert.False(agent.SelfDestructOnComplete);
Assert.True(agent.SelfDestructOnComplete);
}
[Fact]
public void Merge_preserves_keep_logfile_cli_flag_over_remote_false()
{
var agent = NewAgentConfig();
agent.KeepLogFile = true;
var remote = FullRemote();
remote.KeepLogFile = false;
RemoteConfigMerger.Merge(agent, remote, new[] { "--keep-logfile" });
Assert.True(agent.KeepLogFile);
}
[Fact]
public void Merge_preserves_reboot_cli_flag_over_remote_false()
public void Merge_remote_wins_over_prior_agent_reboot_on_complete()
{
var agent = NewAgentConfig();
agent.RebootOnComplete = true;
@@ -126,27 +134,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.RebootOnComplete = false;
RemoteConfigMerger.Merge(agent, remote, new[] { "--reboot-on-complete" });
RemoteConfigMerger.Merge(agent, remote);
Assert.True(agent.RebootOnComplete);
Assert.False(agent.RebootOnComplete);
}
[Fact]
public void Merge_preserves_disable_geolocation_cli_flag_over_remote_true()
{
var agent = NewAgentConfig();
agent.EnableGeoLocation = false;
var remote = FullRemote();
remote.EnableGeoLocation = true;
RemoteConfigMerger.Merge(agent, remote, new[] { "--disable-geolocation" });
Assert.False(agent.EnableGeoLocation);
}
[Fact]
public void Merge_preserves_log_level_cli_arg_over_remote_string()
public void Merge_remote_wins_over_prior_agent_log_level()
{
var agent = NewAgentConfig();
agent.LogLevel = AgentLogLevel.Verbose;
@@ -154,9 +148,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.LogLevel = "Debug";
RemoteConfigMerger.Merge(agent, remote, new[] { "--log-level", "Verbose" });
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(AgentLogLevel.Verbose, agent.LogLevel);
Assert.Equal(AgentLogLevel.Debug, agent.LogLevel);
}
[Fact]
@@ -166,7 +160,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.LogLevel = "trace";
RemoteConfigMerger.Merge(agent, remote, Array.Empty<string>());
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(AgentLogLevel.Trace, agent.LogLevel);
}
@@ -180,11 +174,125 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.LogLevel = "NotAValidLevel";
RemoteConfigMerger.Merge(agent, remote, Array.Empty<string>());
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(AgentLogLevel.Info, agent.LogLevel);
}
[Fact]
public void Merge_remote_null_ntp_server_clears_prior_value()
{
// V1 parity: remote null overwrites local without a IsNullOrWhiteSpace gate.
var agent = NewAgentConfig();
agent.NtpServer = "local.ntp";
var remote = FullRemote();
remote.NtpServer = null!;
RemoteConfigMerger.Merge(agent, remote);
Assert.Null(agent.NtpServer);
}
[Fact]
public void Merge_remote_off_overrides_prior_diagnostics_upload_mode()
{
// Tenant disables diagnostics upload — must take effect even when the agent was
// previously configured (e.g. by a prior config fetch cached on disk) to something
// else. V1 parity.
var agent = NewAgentConfig();
agent.DiagnosticsUploadMode = "OnFailure";
agent.DiagnosticsUploadEnabled = true;
var remote = FullRemote();
remote.DiagnosticsUploadMode = "Off";
remote.DiagnosticsUploadEnabled = false;
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal("Off", agent.DiagnosticsUploadMode);
Assert.False(agent.DiagnosticsUploadEnabled);
}
[Fact]
public void Merge_unrestricted_mode_change_is_reported_via_result()
{
var agent = NewAgentConfig();
agent.UnrestrictedMode = false;
var remote = FullRemote();
remote.UnrestrictedMode = true;
var result = RemoteConfigMerger.Merge(agent, remote);
Assert.True(result.UnrestrictedModeChanged);
Assert.False(result.OldUnrestrictedMode);
Assert.True(result.NewUnrestrictedMode);
Assert.True(agent.UnrestrictedMode);
}
[Fact]
public void Merge_unrestricted_mode_stable_is_not_reported_as_change()
{
var agent = NewAgentConfig();
agent.UnrestrictedMode = true;
var remote = FullRemote();
remote.UnrestrictedMode = true;
var result = RemoteConfigMerger.Merge(agent, remote);
Assert.False(result.UnrestrictedModeChanged);
}
[Fact]
public void Merge_maps_enable_ime_match_log_true_to_default_path()
{
var agent = NewAgentConfig();
agent.ImeMatchLogPath = null;
var remote = FullRemote();
remote.EnableImeMatchLog = true;
RemoteConfigMerger.Merge(agent, remote);
Assert.False(string.IsNullOrEmpty(agent.ImeMatchLogPath));
Assert.Equal(
Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath),
agent.ImeMatchLogPath);
}
[Fact]
public void Merge_maps_enable_ime_match_log_false_clears_path()
{
var agent = NewAgentConfig();
agent.ImeMatchLogPath = Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath);
var remote = FullRemote();
remote.EnableImeMatchLog = false;
RemoteConfigMerger.Merge(agent, remote);
Assert.Null(agent.ImeMatchLogPath);
}
[Fact]
public void Merge_preserves_custom_ime_match_log_path_when_remote_disables()
{
// A dev-time --ime-match-log <custom> override seeds a non-default path on the
// AgentConfiguration. When the tenant remote flips EnableImeMatchLog off we keep
// the custom path rather than clobbering a deliberate dev override.
var agent = NewAgentConfig();
agent.ImeMatchLogPath = @"C:\Temp\custom-ime.log";
var remote = FullRemote();
remote.EnableImeMatchLog = false;
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(@"C:\Temp\custom-ime.log", agent.ImeMatchLogPath);
}
// ============================================================= Null / partial tolerance
[Fact]
@@ -194,22 +302,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
agent.NtpServer = "local.ntp";
agent.SelfDestructOnComplete = true;
RemoteConfigMerger.Merge(agent, null!, Array.Empty<string>());
var result = RemoteConfigMerger.Merge(agent, null!);
Assert.Equal("local.ntp", agent.NtpServer);
Assert.True(agent.SelfDestructOnComplete);
}
[Fact]
public void Merge_accepts_null_cli_args()
{
var agent = NewAgentConfig();
var remote = FullRemote();
RemoteConfigMerger.Merge(agent, remote, null!);
Assert.False(agent.SelfDestructOnComplete);
Assert.Equal("pool.ntp.org", agent.NtpServer);
Assert.NotNull(result);
Assert.False(result.UnrestrictedModeChanged);
}
[Fact]
@@ -222,40 +320,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.Collectors = null!;
RemoteConfigMerger.Merge(agent, remote, Array.Empty<string>());
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(360, agent.AgentMaxLifetimeMinutes);
Assert.Equal(30, agent.HelloWaitTimeoutSeconds);
}
[Fact]
public void Merge_keeps_default_ntp_server_when_remote_is_empty()
{
var agent = NewAgentConfig();
agent.NtpServer = "time.windows.com";
var remote = FullRemote();
remote.NtpServer = "";
RemoteConfigMerger.Merge(agent, remote, Array.Empty<string>());
Assert.Equal("time.windows.com", agent.NtpServer);
}
[Fact]
public void Merge_keeps_default_diagnostics_mode_when_remote_is_null()
{
var agent = NewAgentConfig();
agent.DiagnosticsUploadMode = "Off";
var remote = FullRemote();
remote.DiagnosticsUploadMode = null!;
RemoteConfigMerger.Merge(agent, remote, Array.Empty<string>());
Assert.Equal("Off", agent.DiagnosticsUploadMode);
}
[Fact]
public void Merge_replaces_diagnostics_log_paths_with_empty_list_when_remote_null()
{
@@ -268,7 +338,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.DiagnosticsLogPaths = null!;
RemoteConfigMerger.Merge(agent, remote, Array.Empty<string>());
RemoteConfigMerger.Merge(agent, remote);
Assert.NotNull(agent.DiagnosticsLogPaths);
Assert.Empty(agent.DiagnosticsLogPaths);
@@ -276,20 +346,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
// ============================================================= Int clamping
[Fact]
public void Merge_skips_non_positive_upload_interval()
{
var agent = NewAgentConfig();
agent.UploadIntervalSeconds = 30;
var remote = FullRemote();
remote.UploadIntervalSeconds = 0;
RemoteConfigMerger.Merge(agent, remote, Array.Empty<string>());
Assert.Equal(30, agent.UploadIntervalSeconds);
}
[Fact]
public void Merge_skips_non_positive_max_batch_size()
{
@@ -299,7 +355,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.MaxBatchSize = -1;
RemoteConfigMerger.Merge(agent, remote, Array.Empty<string>());
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(100, agent.MaxBatchSize);
}
@@ -307,6 +363,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
[Fact]
public void Merge_skips_non_positive_hello_wait_timeout()
{
// V1 parity: MonitoringService.ApplyRuntimeSettingsFromRemoteConfig only applies the
// remote value when > 0 so an accidentally misconfigured tenant does not drop the
// Hello-wait to zero.
var agent = NewAgentConfig();
agent.HelloWaitTimeoutSeconds = 30;
@@ -317,7 +376,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
HelloWaitTimeoutSeconds = 0,
};
RemoteConfigMerger.Merge(agent, remote, Array.Empty<string>());
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(30, agent.HelloWaitTimeoutSeconds);
}
@@ -335,7 +394,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
HelloWaitTimeoutSeconds = 30,
};
RemoteConfigMerger.Merge(agent, remote, Array.Empty<string>());
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(0, agent.AgentMaxLifetimeMinutes);
}
@@ -346,26 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
public void Merge_throws_on_null_agent_config()
{
Assert.Throws<ArgumentNullException>(() =>
RemoteConfigMerger.Merge(null!, FullRemote(), Array.Empty<string>()));
}
// ============================================================= The user's scenario
[Fact]
public void Merge_flips_self_destruct_off_when_tenant_admin_disables_cleanup_and_agent_runs_without_cli_args()
{
// Regression guard for the VM smoke-test bug: tenant config has SelfDestructOnComplete=false
// but agent was built with CLI-default true, and without the merger the remote value never
// reached CleanupService / EnrollmentTerminationHandler.
var agent = NewAgentConfig();
agent.SelfDestructOnComplete = true; // BuildAgentConfiguration CLI-default when no --no-cleanup
var remote = FullRemote();
remote.SelfDestructOnComplete = false;
RemoteConfigMerger.Merge(agent, remote, Array.Empty<string>());
Assert.False(agent.SelfDestructOnComplete);
RemoteConfigMerger.Merge(null!, FullRemote()));
}
}
}
@@ -0,0 +1,66 @@
using System;
using AutopilotMonitor.Agent.V2;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// Verifies that <see cref="AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration"/>
/// propagates the dev-only <c>--replay-log-dir</c> + <c>--replay-speed-factor</c> CLI flags
/// onto <see cref="AgentConfiguration"/> so the IME log replay SimulationMode actually
/// activates downstream. V2 parity PR-A.
/// </summary>
public sealed class BuildAgentConfigurationReplayTests
{
private static AgentConfiguration Build(params string[] args) =>
AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration(
args: args,
tenantId: "tenant-t",
sessionId: "session-s",
bootstrapConfig: null,
awaitConfig: null);
[Fact]
public void Without_replay_flags_ReplayLogDir_stays_null_and_speed_factor_default()
{
var cfg = Build();
Assert.Null(cfg.ReplayLogDir);
Assert.Equal(50.0, cfg.ReplaySpeedFactor);
}
[Fact]
public void Replay_log_dir_populates_configuration()
{
var cfg = Build("--replay-log-dir", @"C:\tmp\ime-logs");
Assert.Equal(@"C:\tmp\ime-logs", cfg.ReplayLogDir);
}
[Fact]
public void Replay_speed_factor_is_parsed_as_invariant_double()
{
// Comma locale must NOT flip the meaning — parser uses InvariantCulture.
var cfg = Build("--replay-log-dir", @"C:\tmp\x", "--replay-speed-factor", "125.5");
Assert.Equal(125.5, cfg.ReplaySpeedFactor);
}
[Fact]
public void Replay_speed_factor_zero_is_rejected_and_default_kept()
{
var cfg = Build("--replay-log-dir", @"C:\tmp\x", "--replay-speed-factor", "0");
Assert.Equal(50.0, cfg.ReplaySpeedFactor);
}
[Fact]
public void Replay_speed_factor_malformed_is_rejected_and_default_kept()
{
var cfg = Build("--replay-log-dir", @"C:\tmp\x", "--replay-speed-factor", "fast");
Assert.Equal(50.0, cfg.ReplaySpeedFactor);
}
}
}
@@ -0,0 +1,125 @@
using System.IO;
using System.Reflection;
using AutopilotMonitor.Agent.V2;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// Verifies that <see cref="AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal"/>
/// emits the SessionStarted decision signal with the registration metadata payload the
/// reducer anchor needs. Plan §M4-V2-parity PR-C.
/// </summary>
public sealed class PostSessionStartedSignalTests
{
private static AgentLogger NewLogger(string path)
=> new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
private static SessionRegistrationResult BuildSucceeded(ValidatorType validatedBy, string? adminAction = null)
{
var response = new RegisterSessionResponse
{
SessionId = "session-anon-1",
Success = true,
Message = "ok",
ValidatedBy = validatedBy,
AdminAction = adminAction,
};
// SessionRegistrationResult uses a private ctor + public static factories. Call
// Succeeded via reflection-free factory — it's public.
return SessionRegistrationResult.Succeeded(response);
}
private static AgentConfiguration BuildConfig(bool bootstrap)
{
return new AgentConfiguration
{
ApiBaseUrl = "https://example.invalid",
SessionId = "session-anon-1",
TenantId = "tenant-anon-1",
SpoolDirectory = Path.GetTempPath(),
LogDirectory = Path.GetTempPath(),
LogLevel = AgentLogLevel.Info,
UseClientCertAuth = !bootstrap,
UseBootstrapTokenAuth = bootstrap,
BootstrapToken = bootstrap ? "dummy" : null,
};
}
[Fact]
public void Posts_session_started_kind_with_default_schema_version()
{
using var tmp = new TempDirectory();
var sink = new FakeSignalIngressSink();
var logger = NewLogger(tmp.Path);
var result = BuildSucceeded(ValidatorType.AutopilotV1);
AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: false), logger);
Assert.Single(sink.Posted);
var posted = sink.Posted[0];
Assert.Equal(DecisionSignalKind.SessionStarted, posted.Kind);
Assert.Equal(1, posted.KindSchemaVersion);
Assert.Equal("Program.RunAgent", posted.SourceOrigin);
Assert.Equal(EvidenceKind.Synthetic, posted.Evidence.Kind);
Assert.Equal("register_session_success", posted.Evidence.Identifier);
}
[Fact]
public void Payload_includes_registration_metadata_keys()
{
using var tmp = new TempDirectory();
var sink = new FakeSignalIngressSink();
var logger = NewLogger(tmp.Path);
var result = BuildSucceeded(ValidatorType.CorporateIdentifier);
AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: true), logger);
var payload = sink.Posted[0].Payload;
Assert.NotNull(payload);
Assert.Equal("CorporateIdentifier", payload!["validatedBy"]);
Assert.Equal("true", payload["isBootstrapSession"]);
Assert.Contains("enrollmentType", (System.Collections.Generic.IDictionary<string, string>)payload);
Assert.Contains("isHybridJoin", (System.Collections.Generic.IDictionary<string, string>)payload);
Assert.Contains("agentVersion", (System.Collections.Generic.IDictionary<string, string>)payload);
}
[Fact]
public void Swallows_sink_exceptions()
{
// The helper is called on the hot startup path — a misbehaving sink must not
// crash the agent.
using var tmp = new TempDirectory();
var sink = new ThrowingSink();
var logger = NewLogger(tmp.Path);
var result = BuildSucceeded(ValidatorType.Bootstrap);
var ex = Record.Exception(() =>
AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: false), logger));
Assert.Null(ex);
}
private sealed class ThrowingSink : AutopilotMonitor.Agent.V2.Core.Orchestration.ISignalIngressSink
{
public void Post(
DecisionSignalKind kind,
System.DateTime occurredAtUtc,
string sourceOrigin,
Evidence evidence,
System.Collections.Generic.IReadOnlyDictionary<string, string>? payload = null,
int kindSchemaVersion = 1)
{
throw new System.InvalidOperationException("ingress unavailable");
}
}
}
}
@@ -1,8 +1,12 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
@@ -226,6 +230,77 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
new AuthFailureTracker(1, 0, new FakeClock(DateTime.UtcNow), null!));
}
// ============================================================= Distress dispatch (V1 parity)
[Fact]
public void RecordFailure_dispatches_distress_only_on_first_failure_for_401()
{
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
var fakeDistress = new FakeDistressReporter();
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
tracker.RecordFailure(401, "agent/config");
tracker.RecordFailure(401, "agent/config");
tracker.RecordFailure(401, "agent/config");
Assert.Single(fakeDistress.Calls);
Assert.Equal(DistressErrorType.AuthCertificateRejected, fakeDistress.Calls[0].ErrorType);
Assert.Equal(401, fakeDistress.Calls[0].HttpStatusCode);
Assert.Contains("agent/config", fakeDistress.Calls[0].Message);
}
[Fact]
public void RecordFailure_dispatches_device_not_registered_for_403()
{
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
var fakeDistress = new FakeDistressReporter();
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
tracker.RecordFailure(403, "agent/telemetry");
Assert.Single(fakeDistress.Calls);
Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[0].ErrorType);
Assert.Equal(403, fakeDistress.Calls[0].HttpStatusCode);
}
[Fact]
public void RecordFailure_without_distress_reporter_is_silent_but_still_tracks()
{
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
var tracker = new AuthFailureTracker(maxFailures: 2, timeoutMinutes: 0, clock, NewLogger());
int events = 0;
tracker.ThresholdExceeded += (_, _) => events++;
tracker.RecordFailure(401, "op");
tracker.RecordFailure(401, "op");
Assert.Equal(1, events);
}
private sealed class FakeDistressReporter : DistressReporter
{
public sealed class Call
{
public DistressErrorType ErrorType { get; set; }
public string Message { get; set; } = default!;
public int? HttpStatusCode { get; set; }
}
public List<Call> Calls { get; } = new List<Call>();
public FakeDistressReporter()
: base(baseUrl: "https://example.test", tenantId: "t", manufacturer: "m", model: "d", serialNumber: "s", agentVersion: "0.0.0", logger: null!)
{
}
public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null)
{
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode });
return Task.CompletedTask;
}
}
private sealed class FakeClock : AutopilotMonitor.DecisionCore.Engine.IClock
{
public DateTime Now { get; set; }
@@ -1,4 +1,7 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
@@ -96,5 +99,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", hash);
}
[Fact]
public void Check_match_does_not_invoke_runtime_self_update_trigger()
{
BinaryIntegrityVerifier.ResetTriggerForTests();
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var exe = Path.Combine(tmp.Path, "fake.exe");
File.WriteAllBytes(exe, new byte[] { 0x01, 0x02 });
var actual = BinaryIntegrityVerifier.ComputeSha256Hex(exe);
var triggerCount = 0;
Func<string, bool, Task> trigger = (_, __) =>
{
Interlocked.Increment(ref triggerCount);
return Task.CompletedTask;
};
var result = BinaryIntegrityVerifier.Check(actual, logger, exePath: exe,
runtimeSelfUpdateTrigger: trigger, zipHash: "zip", allowDowngrade: false);
Assert.Equal(IntegrityCheckOutcome.Match, result.Outcome);
Assert.Equal(0, Volatile.Read(ref triggerCount));
}
[Fact]
public void Check_mismatch_fires_runtime_self_update_trigger_exactly_once()
{
// V1 parity + single-shot guard: repeat calls at runtime (e.g. config-rotation hash
// drift) must not fan out into multiple force-update attempts.
BinaryIntegrityVerifier.ResetTriggerForTests();
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var exe = Path.Combine(tmp.Path, "fake.exe");
File.WriteAllBytes(exe, new byte[] { 0x01 });
var wrongHash = new string('f', 64);
var triggerCount = 0;
string capturedZipHash = null;
bool capturedDowngrade = false;
var triggerDone = new ManualResetEventSlim(false);
Func<string, bool, Task> trigger = (zip, down) =>
{
Interlocked.Increment(ref triggerCount);
capturedZipHash = zip;
capturedDowngrade = down;
triggerDone.Set();
return Task.CompletedTask;
};
var r1 = BinaryIntegrityVerifier.Check(wrongHash, logger, exePath: exe,
runtimeSelfUpdateTrigger: trigger, zipHash: "zip-hash", allowDowngrade: true);
var r2 = BinaryIntegrityVerifier.Check(wrongHash, logger, exePath: exe,
runtimeSelfUpdateTrigger: trigger, zipHash: "zip-hash", allowDowngrade: true);
Assert.Equal(IntegrityCheckOutcome.Mismatch, r1.Outcome);
Assert.Equal(IntegrityCheckOutcome.Mismatch, r2.Outcome);
Assert.True(triggerDone.Wait(TimeSpan.FromSeconds(10)), "trigger did not run within 10s");
Assert.Equal(1, Volatile.Read(ref triggerCount));
Assert.Equal("zip-hash", capturedZipHash);
Assert.True(capturedDowngrade);
}
[Fact]
public void Check_mismatch_without_trigger_only_logs()
{
BinaryIntegrityVerifier.ResetTriggerForTests();
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var exe = Path.Combine(tmp.Path, "fake.exe");
File.WriteAllBytes(exe, new byte[] { 0x07 });
var wrongHash = new string('e', 64);
var result = BinaryIntegrityVerifier.Check(wrongHash, logger, exePath: exe,
runtimeSelfUpdateTrigger: null);
Assert.Equal(IntegrityCheckOutcome.Mismatch, result.Outcome);
}
}
}
@@ -0,0 +1,167 @@
using System;
using System.IO;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// V1 parity guard for <see cref="SessionRegistrationHelper"/>. The helper's retry + short-
/// circuit behaviour is the single replacement for V1 <c>MonitoringService.RegisterSessionAsync</c>.
/// If these tests break, production enrollment registration is at risk.
/// </summary>
public sealed class SessionRegistrationHelperTests
{
private static AgentLogger NewLogger(TempDirectory tmp)
=> new AgentLogger(Path.Combine(tmp.Path, "logs"), AgentLogLevel.Debug);
private static AgentConfiguration NewConfig() => new AgentConfiguration
{
ApiBaseUrl = "https://example.test",
SessionId = Guid.NewGuid().ToString(),
TenantId = Guid.NewGuid().ToString(),
};
[Fact]
public async Task Succeeds_on_first_response_success()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(attempts => new RegisterSessionResponse
{
Success = true,
SessionId = config.SessionId,
ValidatedBy = ValidatorType.AutopilotV1,
});
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => Task.CompletedTask);
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
Assert.Equal(1, apiClient.CallCount);
Assert.Equal(ValidatorType.AutopilotV1, result.ValidatedBy);
Assert.Null(result.AdminAction);
}
[Fact]
public async Task Retries_until_success_on_success_false()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(attempt =>
{
if (attempt < 3) return new RegisterSessionResponse { Success = false, Message = "backend 500" };
return new RegisterSessionResponse { Success = true, SessionId = config.SessionId };
});
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => Task.CompletedTask);
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
Assert.Equal(3, apiClient.CallCount);
}
[Fact]
public async Task Auth_failure_short_circuits_without_retry()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(attempt =>
throw new BackendAuthException("cert rejected", statusCode: 401));
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
clock: SystemClock.Instance, logger: logger);
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger, authFailureTracker: tracker);
Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome);
Assert.Equal(401, result.HttpStatusCode);
Assert.Equal(1, apiClient.CallCount);
Assert.Equal(1, tracker.ConsecutiveFailures);
}
[Fact]
public async Task Fails_after_five_exceptions_without_auth_error()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(attempt =>
throw new InvalidOperationException($"transient attempt {attempt}"));
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => Task.CompletedTask);
Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
Assert.Equal(5, apiClient.CallCount);
Assert.Contains("transient attempt 5", result.ErrorMessage);
}
[Fact]
public async Task Populates_registration_with_device_and_version_details()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
SessionRegistration captured = null!;
var apiClient = new FakeApiClient(
_ => new RegisterSessionResponse { Success = true, SessionId = config.SessionId },
onRegister: reg => captured = reg);
await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "9.9.9", logger: logger);
Assert.NotNull(captured);
Assert.Equal(config.SessionId, captured.SessionId);
Assert.Equal(config.TenantId, captured.TenantId);
Assert.Equal("9.9.9", captured.AgentVersion);
Assert.Equal(Environment.MachineName, captured.DeviceName);
Assert.False(string.IsNullOrEmpty(captured.EnrollmentType));
Assert.True(captured.IsUserDriven);
}
// Subclasses the real BackendApiClient so we inherit its public API but intercept the
// single virtual entry point that matters. BackendApiClient's ctor is protected-less in
// V2 (`public BackendApiClient(string baseUrl, ...)` + `protected BackendApiClient()`);
// we use the protected ctor to avoid HttpClient construction side effects in unit tests.
private sealed class FakeApiClient : BackendApiClient
{
private readonly Func<int, RegisterSessionResponse> _respond;
private readonly Action<SessionRegistration> _onRegister;
public int CallCount;
public FakeApiClient(Func<int, RegisterSessionResponse> respond, Action<SessionRegistration> onRegister = null)
{
_respond = respond;
_onRegister = onRegister;
}
public override Task<RegisterSessionResponse> RegisterSessionAsync(SessionRegistration registration)
{
CallCount++;
_onRegister?.Invoke(registration);
try { return Task.FromResult(_respond(CallCount)); }
catch (Exception ex) { return Task.FromException<RegisterSessionResponse>(ex); }
}
}
}
}
@@ -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)
@@ -121,9 +121,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
}
/// <summary>
/// Registers a new enrollment session
/// Registers a new enrollment session.
/// <para>
/// <c>virtual</c> so test doubles (e.g. <c>SessionRegistrationHelperTests.FakeApiClient</c>)
/// can intercept the HTTP call without spinning up a real mTLS stack. Production path is
/// unchanged — the virtual dispatch cost is negligible vs. the network round-trip.
/// </para>
/// </summary>
public async Task<RegisterSessionResponse> RegisterSessionAsync(SessionRegistration registration)
public virtual async Task<RegisterSessionResponse> RegisterSessionAsync(SessionRegistration registration)
{
var request = new RegisterSessionRequest { Registration = registration };
var endpoint = _useBootstrapTokenAuth
@@ -36,11 +36,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </list>
/// Optional peripheral hosts (driven by <see cref="CollectorConfiguration"/> toggles):
/// <list type="bullet">
/// <item><b>PerformanceHost</b> — CPU/memory/disk samples.</item>
/// <item><b>AgentSelfMetricsHost</b> — process CPU, memory and HTTP traffic counters.
/// Wires into the <see cref="NetworkMetrics"/> instance created by the Program.cs
/// <see cref="BackendApiClient"/> (the V2 <see cref="Transport.Telemetry.BackendTelemetryUploader"/>
/// has no metrics — accepted M4.5.b tech-debt).</item>
/// <item><b>PeriodicCollectorLifecycleHost</b> — owns <c>PerformanceCollector</c> (CPU /
/// memory / disk samples) and <c>AgentSelfMetricsCollector</c> (process CPU, memory and
/// HTTP traffic counters) under a single idle-timeout window (V1 parity with
/// <c>PeriodicCollectorManager</c>). Wires <c>AgentSelfMetricsCollector</c> into the
/// <see cref="NetworkMetrics"/> instance created by the Program.cs
/// <see cref="BackendApiClient"/>.</item>
/// <item><b>NetworkChangeHost</b> — owns <c>NetworkChangeDetector</c> for WiFi / SSID /
/// default-route / MDM-reachability transitions (V1 parity with
/// <c>CollectorCoordinator.StartOptionalCollectors:375-382</c>).</item>
/// </list>
/// </para>
/// <para>
@@ -122,6 +126,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
hosts.Add(new AadJoinHost(logger, ingress, clock));
// Dev / test — if --replay-log-dir is set, the tracker reads from the replay folder
// with SimulationMode ON + the configured SpeedFactor instead of tailing the live
// IME log folder. Production agents leave ReplayLogDir empty.
var simulationMode = !string.IsNullOrEmpty(_agentConfig.ReplayLogDir);
var imeLogFolder = simulationMode
? _agentConfig.ReplayLogDir
: _agentConfig.ImeLogPathOverride;
_imeLogHost = new ImeLogHost(
sessionId: sessionId,
tenantId: tenantId,
@@ -129,11 +141,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
logger: logger,
ingress: ingress,
clock: clock,
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
imeLogPathOverride: imeLogFolder,
imeMatchLogPath: _agentConfig.ImeMatchLogPath,
imePatterns: _remoteConfig.ImeLogPatterns,
stateDirectory: _stateDirectory,
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds);
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
simulationMode: simulationMode,
simulationSpeedFactor: _agentConfig.ReplaySpeedFactor);
hosts.Add(_imeLogHost);
if (collectors.StallProbeEnabled)
@@ -154,17 +168,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// ----- Peripheral hosts (event-only; driven by remote-config toggles) --------------
if (collectors.EnablePerformanceCollector)
// V1 parity (PeriodicCollectorManager) — combine Performance + AgentSelfMetrics under
// a single host that stops both after CollectorIdleTimeoutMinutes of no real enrollment
// activity and restarts them on the next real event. Without the idle timeout the two
// collectors run for the agent's entire lifetime (up to AgentMaxLifetime / 6 h),
// which drains battery + wastes bandwidth on dormant sessions.
if (collectors.EnablePerformanceCollector || (collectors.EnableAgentSelfMetrics && _networkMetrics != null))
{
hosts.Add(new PerformanceHost(sessionId, tenantId, onEnrollmentEvent, logger,
collectors.PerformanceIntervalSeconds));
hosts.Add(new PeriodicCollectorLifecycleHost(
sessionId: sessionId,
tenantId: tenantId,
onEnrollmentEvent: onEnrollmentEvent,
logger: logger,
performanceEnabled: collectors.EnablePerformanceCollector,
performanceIntervalSeconds: collectors.PerformanceIntervalSeconds,
selfMetricsEnabled: collectors.EnableAgentSelfMetrics && _networkMetrics != null,
selfMetricsIntervalSeconds: collectors.AgentSelfMetricsIntervalSeconds,
idleTimeoutMinutes: collectors.CollectorIdleTimeoutMinutes,
networkMetrics: _networkMetrics,
agentVersion: _agentVersion));
}
if (collectors.EnableAgentSelfMetrics && _networkMetrics != null)
{
hosts.Add(new AgentSelfMetricsHost(sessionId, tenantId, onEnrollmentEvent, logger,
_networkMetrics, _agentVersion, collectors.AgentSelfMetricsIntervalSeconds));
}
// V1 parity (CollectorCoordinator.StartOptionalCollectors:375-382) — wire the
// NetworkChangeDetector. It captures WiFi SSID / default route / IPv4 / reachability
// changes and emits `network_change` events. Events are already debounced internally
// (5s); no separate remote-config toggle in V1 either.
hosts.Add(new NetworkChangeHost(
sessionId: sessionId,
tenantId: tenantId,
onEnrollmentEvent: onEnrollmentEvent,
logger: logger,
apiBaseUrl: _agentConfig.ApiBaseUrl));
// M4.6.γ — Delivery-Optimization telemetry. Dormant-by-default: only polls when the
// IME log tracker reports an app entering Downloading/Installing (see AppStateChanged
@@ -192,7 +226,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
onEnrollmentEvent: onEnrollmentEvent,
logger: logger,
rules: _remoteConfig.GatherRules,
imeLogPathOverride: _agentConfig.ImeLogPathOverride));
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
unrestrictedMode: _agentConfig.UnrestrictedMode));
}
return hosts;
@@ -209,6 +244,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly Monitoring.Telemetry.Gather.GatherRuleExecutor _executor;
private readonly System.Collections.Generic.List<AutopilotMonitor.Shared.Models.GatherRule> _rules;
private readonly AgentLogger _logger;
private readonly bool _unrestrictedMode;
private int _disposed;
public GatherRuleExecutorHost(
@@ -217,18 +253,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
Action<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()
@@ -368,6 +411,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
internal Monitoring.Enrollment.Ime.ImeLogTracker Tracker => _tracker;
/// <summary>
/// Exposes the tracker's simulation flag so the Dev / Test CLI flag
/// <c>--replay-log-dir</c> is testable without poking through reflection.
/// </summary>
internal bool IsSimulationMode => _tracker.SimulationMode;
public ImeLogHost(
string sessionId,
string tenantId,
@@ -379,7 +428,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
string? imeMatchLogPath,
List<ImeLogPattern>? imePatterns,
string stateDirectory,
IReadOnlyCollection<string>? whiteGloveSealingPatternIds)
IReadOnlyCollection<string>? whiteGloveSealingPatternIds,
bool simulationMode = false,
double simulationSpeedFactor = 50)
{
_logger = logger;
var logFolder = string.IsNullOrEmpty(imeLogPathOverride) ? DefaultImeLogFolder : imeLogPathOverride!;
@@ -395,6 +446,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
matchLogPath: expandedMatchLogPath,
stateDirectory: stateDirectory);
if (simulationMode)
{
_tracker.SimulationMode = true;
_tracker.SpeedFactor = simulationSpeedFactor;
logger.Info($"ImeLogHost: SimulationMode ENABLED (speedFactor={simulationSpeedFactor}, path={logFolder})");
}
_adapter = new ImeLogTrackerAdapter(_tracker, ingress, clock, whiteGloveSealingPatternIds);
_processWatcher = new ImeProcessWatcher(sessionId, tenantId, onEnrollmentEvent, logger);
@@ -507,33 +565,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
private sealed class PerformanceHost : ICollectorHost
{
public string Name => "PerformanceCollector";
private readonly PerformanceCollector _collector;
private int _disposed;
public PerformanceHost(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEnrollmentEvent,
AgentLogger logger,
int intervalSeconds)
{
_collector = new PerformanceCollector(sessionId, tenantId, onEnrollmentEvent, logger, intervalSeconds);
}
public void Start() => _collector.Start();
public void Stop() => _collector.Stop();
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
try { _collector.Dispose(); } catch { }
}
}
private sealed class DeliveryOptimizationHost : ICollectorHost
{
public string Name => "DeliveryOptimizationCollector";
@@ -570,13 +601,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
logDirectory: Environment.ExpandEnvironmentVariables(Shared.Constants.LogDirectory));
}
private Action<Monitoring.Enrollment.Ime.AppPackageState, Monitoring.Enrollment.Ime.AppInstallationState, Monitoring.Enrollment.Ime.AppInstallationState>? _chainedHandler;
public void Start()
{
// Chain ourselves into the tracker's OnAppStateChanged so the DO collector wakes
// up on the first Downloading/Installing transition (Legacy parity). We preserve
// any existing handler — the IME adapter's own listener must keep running.
_prevStateChanged = _imeHost.Tracker.OnAppStateChanged;
_imeHost.Tracker.OnAppStateChanged = (pkg, oldState, newState) =>
_chainedHandler = (pkg, oldState, newState) =>
{
try { _prevStateChanged?.Invoke(pkg, oldState, newState); }
catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: previous OnAppStateChanged handler threw: {ex.Message}"); }
@@ -588,6 +621,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: WakeUp threw: {ex.Message}"); }
}
};
_imeHost.Tracker.OnAppStateChanged = _chainedHandler;
_collector.Start();
_logger.Info($"DeliveryOptimizationHost: started dormant (interval={_collector}, wakes on Downloading/Installing).");
@@ -596,9 +630,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
public void Stop()
{
// Restore the previous handler only if we're still the current one — otherwise
// someone else replaced us and owns the slot now.
if (object.ReferenceEquals(_imeHost.Tracker.OnAppStateChanged, _imeHost.Tracker.OnAppStateChanged))
// someone else replaced us and owns the slot now. The V1 bug here compared the
// handler slot against itself (always true) which silently overwrote any newer
// handler that had since taken over; the captured `_chainedHandler` reference
// lets us make the check actually meaningful.
if (_chainedHandler != null
&& object.ReferenceEquals(_imeHost.Tracker.OnAppStateChanged, _chainedHandler))
{
_imeHost.Tracker.OnAppStateChanged = _prevStateChanged;
}
_collector.Stop();
}
@@ -609,33 +649,273 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
private sealed class AgentSelfMetricsHost : ICollectorHost
/// <summary>
/// V1 parity (<c>PeriodicCollectorManager</c>). Owns the <see cref="PerformanceCollector"/>
/// and <see cref="AgentSelfMetricsCollector"/> and enforces the
/// <c>CollectorIdleTimeoutMinutes</c> window: both stop after N minutes of no real event
/// activity and restart on the next real (non-periodic) event. Without this the two
/// collectors ran forever in V2 and filled the telemetry spool on dormant sessions.
/// <para>
/// Activity is observed by wrapping the <c>onEnrollmentEvent</c> sink: every event that
/// flows through this host bumps <c>_lastRealEventTimeUtc</c> unless it matches a
/// well-known periodic event type (<c>performance_snapshot</c>, <c>agent_metrics_snapshot</c>,
/// <c>stall_probe_*</c>, <c>session_stalled</c>). Events emitted by the managed
/// collectors themselves therefore never reset their own idle window.
/// </para>
/// </summary>
private sealed class PeriodicCollectorLifecycleHost : ICollectorHost
{
public string Name => "AgentSelfMetricsCollector";
public string Name => "PeriodicCollectorLifecycleHost";
private readonly AgentSelfMetricsCollector _collector;
private static readonly TimeSpan IdleTickInterval = TimeSpan.FromSeconds(60);
private static readonly HashSet<string> PeriodicEventTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"performance_snapshot",
"agent_metrics_snapshot",
"performance_collector_stopped",
"agent_metrics_collector_stopped",
"stall_probe_check",
"stall_probe_result",
"session_stalled",
};
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly AgentLogger _logger;
private readonly bool _perfEnabled;
private readonly int _perfIntervalSeconds;
private readonly bool _selfMetricsEnabled;
private readonly int _selfMetricsIntervalSeconds;
private readonly int _idleTimeoutMinutes;
private readonly NetworkMetrics? _networkMetrics;
private readonly string _agentVersion;
private readonly object _sync = new object();
private PerformanceCollector? _performanceCollector;
private AgentSelfMetricsCollector? _selfMetricsCollector;
private Timer? _idleTimer;
private DateTime _lastRealEventTimeUtc;
private bool _idleStopped;
private int _disposed;
public AgentSelfMetricsHost(
public PeriodicCollectorLifecycleHost(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEnrollmentEvent,
AgentLogger logger,
NetworkMetrics networkMetrics,
string agentVersion,
int intervalSeconds)
bool performanceEnabled,
int performanceIntervalSeconds,
bool selfMetricsEnabled,
int selfMetricsIntervalSeconds,
int idleTimeoutMinutes,
NetworkMetrics? networkMetrics,
string agentVersion)
{
_collector = new AgentSelfMetricsCollector(
sessionId, tenantId, onEnrollmentEvent, networkMetrics, logger, agentVersion, intervalSeconds);
_sessionId = sessionId;
_tenantId = tenantId;
_emitEvent = WrapEventSink(onEnrollmentEvent);
_logger = logger;
_perfEnabled = performanceEnabled;
_perfIntervalSeconds = performanceIntervalSeconds;
_selfMetricsEnabled = selfMetricsEnabled;
_selfMetricsIntervalSeconds = selfMetricsIntervalSeconds;
_idleTimeoutMinutes = idleTimeoutMinutes;
_networkMetrics = networkMetrics;
_agentVersion = agentVersion;
_lastRealEventTimeUtc = DateTime.UtcNow;
}
public void Start() => _collector.Start();
public void Stop() => _collector.Stop();
public void Start()
{
lock (_sync)
{
StartCollectorsInternal();
if (_idleTimeoutMinutes > 0)
{
_idleTimer = new Timer(_ => IdleCheckTick(), state: null,
dueTime: IdleTickInterval, period: IdleTickInterval);
_logger.Info($"PeriodicCollectorLifecycleHost: started (idle timeout={_idleTimeoutMinutes}min).");
}
else
{
_logger.Info("PeriodicCollectorLifecycleHost: started (idle timeout disabled).");
}
}
}
public void Stop()
{
lock (_sync)
{
try { _idleTimer?.Dispose(); } catch { }
_idleTimer = null;
StopCollectorsInternal();
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
try { _collector.Dispose(); } catch { }
Stop();
}
private void StartCollectorsInternal()
{
if (_perfEnabled && _performanceCollector == null)
{
_performanceCollector = new PerformanceCollector(
_sessionId, _tenantId, _emitEvent, _logger, _perfIntervalSeconds);
_performanceCollector.Start();
}
if (_selfMetricsEnabled && _selfMetricsCollector == null && _networkMetrics != null)
{
_selfMetricsCollector = new AgentSelfMetricsCollector(
_sessionId, _tenantId, _emitEvent, _networkMetrics, _logger, _agentVersion, _selfMetricsIntervalSeconds);
_selfMetricsCollector.Start();
}
_idleStopped = false;
}
private void StopCollectorsInternal()
{
try { _performanceCollector?.Stop(); } catch { }
try { _performanceCollector?.Dispose(); } catch { }
_performanceCollector = null;
try { _selfMetricsCollector?.Stop(); } catch { }
try { _selfMetricsCollector?.Dispose(); } catch { }
_selfMetricsCollector = null;
}
private Action<EnrollmentEvent> WrapEventSink(Action<EnrollmentEvent> inner)
{
return evt =>
{
if (evt == null)
{
inner(null!);
return;
}
if (!string.IsNullOrEmpty(evt.EventType)
&& !PeriodicEventTypes.Contains(evt.EventType))
{
OnRealEvent();
}
inner(evt);
};
}
private void OnRealEvent()
{
lock (_sync)
{
_lastRealEventTimeUtc = DateTime.UtcNow;
if (_idleStopped)
{
_logger.Info("PeriodicCollectorLifecycleHost: real event detected — restarting periodic collectors.");
StartCollectorsInternal();
}
}
}
private void IdleCheckTick()
{
try
{
lock (_sync)
{
if (_idleStopped) return;
if (_idleTimeoutMinutes <= 0) return;
var idleMinutes = (DateTime.UtcNow - _lastRealEventTimeUtc).TotalMinutes;
if (idleMinutes < _idleTimeoutMinutes) return;
_logger.Info($"PeriodicCollectorLifecycleHost: idle for {idleMinutes:F0}min (limit={_idleTimeoutMinutes}) — stopping collectors.");
var hadPerformance = _performanceCollector != null;
var hadSelfMetrics = _selfMetricsCollector != null;
StopCollectorsInternal();
_idleStopped = true;
if (hadPerformance)
EmitIdleStopped("performance_collector_stopped", idleMinutes);
if (hadSelfMetrics)
EmitIdleStopped("agent_metrics_collector_stopped", idleMinutes);
}
}
catch (Exception ex)
{
_logger.Warning($"PeriodicCollectorLifecycleHost: idle-check tick threw: {ex.Message}");
}
}
private void EmitIdleStopped(string eventType, double idleMinutes)
{
try
{
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = eventType,
Severity = EventSeverity.Info,
Source = "PeriodicCollectorLifecycleHost",
Phase = EnrollmentPhase.Unknown,
Message = $"{eventType} after {idleMinutes:F0}min idle (no real enrollment activity).",
Data = new Dictionary<string, object>
{
{ "reason", "idle_timeout" },
{ "idleTimeoutMinutes", _idleTimeoutMinutes },
{ "idleMinutes", Math.Round(idleMinutes, 1) },
},
});
}
catch (Exception ex) { _logger.Debug($"PeriodicCollectorLifecycleHost: emit '{eventType}' threw: {ex.Message}"); }
}
}
/// <summary>
/// V1 parity (<c>CollectorCoordinator.StartOptionalCollectors:375-382</c>) — owns the
/// lifecycle of <see cref="NetworkChangeDetector"/>. Without this host the detector class
/// existed in V2 but was never instantiated, so WiFi / SSID / connectivity transitions
/// were invisible to the backend.
/// </summary>
private sealed class NetworkChangeHost : ICollectorHost
{
public string Name => "NetworkChangeDetector";
private readonly NetworkChangeDetector _detector;
private readonly AgentLogger _logger;
private int _disposed;
public NetworkChangeHost(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEnrollmentEvent,
AgentLogger logger,
string? apiBaseUrl)
{
_logger = logger;
_detector = new NetworkChangeDetector(sessionId, tenantId, onEnrollmentEvent, logger, apiBaseUrl);
}
public void Start()
{
try { _detector.Start(); }
catch (Exception ex) { _logger.Warning($"NetworkChangeHost: Start failed: {ex.Message}"); }
}
public void Stop()
{
try { _detector.Stop(); }
catch (Exception ex) { _logger.Warning($"NetworkChangeHost: Stop failed: {ex.Message}"); }
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
try { _detector.Dispose(); } catch { }
}
}
}
@@ -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))
@@ -0,0 +1,75 @@
using System;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Read-only registry probes for enrollment metadata the agent has to pin down at session
/// registration time. V1 parity — these mirror <c>EnrollmentTracker.DetectHybridJoinStatic</c>
/// and <c>EnrollmentTracker.DetectEnrollmentTypeStatic</c> from the legacy agent.
/// <para>
/// Kept stateless and exception-swallowing so the registration pipeline can call them
/// before any other component is up. A missing / inaccessible registry key degrades to
/// the default value (v1 / non-hybrid) rather than throwing — mirrors V1 behaviour.
/// </para>
/// </summary>
public static class EnrollmentRegistryDetector
{
private const string AutopilotPolicyCacheKey = @"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache";
private const string AutopilotSettingsKey = @"SOFTWARE\Microsoft\Provisioning\AutopilotSettings";
/// <summary>
/// Reads <c>CloudAssignedDomainJoinMethod</c> from the Autopilot policy cache. Returns
/// <c>true</c> when the profile was deployed with Hybrid Azure AD Join
/// (<c>CloudAssignedDomainJoinMethod == 1</c>), <c>false</c> otherwise or on any error.
/// </summary>
public static bool DetectHybridJoin()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(AutopilotPolicyCacheKey))
{
if (key == null) return false;
var domainJoinMethod = key.GetValue("CloudAssignedDomainJoinMethod")?.ToString();
return domainJoinMethod == "1";
}
}
catch
{
return false;
}
}
/// <summary>
/// Classifies the Autopilot flow based on the <c>AutopilotSettings</c> registry:
/// <list type="bullet">
/// <item><c>CloudAssignedDeviceRegistration == 2</c> → <c>"v2"</c> (Windows Device Preparation).</item>
/// <item><c>CloudAssignedEspEnabled == 0</c> → <c>"v2"</c> (no ESP, WDP indicator).</item>
/// <item>Anything else → <c>"v1"</c> (Classic Autopilot / ESP).</item>
/// </list>
/// Defaults to <c>"v1"</c> on any error so a flaky registry does not misclassify a
/// classic session as WDP.
/// </summary>
public static string DetectEnrollmentType()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(AutopilotSettingsKey))
{
if (key == null) return "v1";
var deviceReg = key.GetValue("CloudAssignedDeviceRegistration")?.ToString();
if (deviceReg == "2") return "v2";
var espEnabled = key.GetValue("CloudAssignedEspEnabled")?.ToString();
if (espEnabled == "0") return "v2";
}
}
catch
{
// Fall through to v1 default.
}
return "v1";
}
}
}
@@ -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)
{
@@ -0,0 +1,194 @@
using System;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// V1-parity wrapper around <see cref="BackendApiClient.RegisterSessionAsync"/>. Plan §3.9 /
/// PR #51. The backend's <c>/api/agent/register-session</c> is the authoritative point where
/// a session row is created in the <c>Sessions</c> table; without it, every subsequent
/// <c>IncrementSessionEventCountAsync</c> / <c>UpdateSessionStatusAsync</c> silently no-ops
/// (the server logs a warning and returns) so events still land but session status,
/// phase, admin-overrides and validator reconciliation break.
/// <para>
/// <b>Retry contract (V1 <c>MonitoringService.RegisterSessionAsync</c>):</b>
/// </para>
/// <list type="bullet">
/// <item>5 attempts total; between attempts <c>2^attempt</c> seconds (2s, 4s, 8s, 16s).</item>
/// <item>On <c>response.Success == true</c> → stop immediately.</item>
/// <item>On <see cref="BackendAuthException"/> (401/403) → report to the
/// <see cref="AuthFailureTracker"/> (which fires the first-failure distress + may trip
/// the shutdown threshold) and return <see cref="SessionRegistrationOutcome.AuthFailed"/>
/// without retrying — the backend has definitively rejected the device cert.</item>
/// <item>Any other exception on the last attempt → <see cref="EmergencyReporter.TrySendAsync"/>
/// with <c>AgentErrorType.RegisterSessionFailed</c> so operators see the final cause.</item>
/// </list>
/// <para>
/// The caller (<c>Program.RunAgent</c>) must treat a non-<see cref="SessionRegistrationOutcome.Succeeded"/>
/// outcome as fatal — V1 parity: <c>"=== SESSION REGISTRATION FAILED — collectors will NOT start
/// to prevent orphaned events ==="</c>. The agent exits cleanly instead of spinning up
/// the orchestrator and flooding the Events table for an unregistered session.
/// </para>
/// </summary>
public static class SessionRegistrationHelper
{
private const int MaxAttempts = 5;
/// <summary>
/// Executes the 5-retry register-session handshake against the backend. Returns the
/// response + classified outcome. Never throws.
/// </summary>
/// <param name="backoffDelay">
/// Optional delay-provider used by tests to avoid real-time waits. Production callers
/// pass <c>null</c> → V1 parity <c>Task.Delay(2^attempt * 1000)</c> between attempts.
/// </param>
public static async Task<SessionRegistrationResult> RegisterWithRetryAsync(
BackendApiClient apiClient,
AgentConfiguration agentConfig,
string agentVersion,
AgentLogger logger,
AuthFailureTracker authFailureTracker = null,
EmergencyReporter emergencyReporter = null,
Func<int, Task> backoffDelay = null)
{
if (apiClient == null) throw new ArgumentNullException(nameof(apiClient));
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
if (logger == null) throw new ArgumentNullException(nameof(logger));
backoffDelay ??= DefaultBackoffDelay;
var registration = BuildRegistration(agentConfig, agentVersion);
string lastError = null;
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
try
{
logger.Info($"Registering session with backend (attempt {attempt}/{MaxAttempts})");
var response = await apiClient.RegisterSessionAsync(registration).ConfigureAwait(false);
if (response != null && response.Success)
{
logger.Info($"Session registered successfully: {response.SessionId} (validatedBy={response.ValidatedBy}, adminAction={response.AdminAction ?? "(none)"})");
return SessionRegistrationResult.Succeeded(response);
}
lastError = response?.Message ?? "(null response)";
logger.Warning($"Session registration failed: {lastError}");
}
catch (BackendAuthException ex)
{
logger.Error($"Session registration authentication failed ({ex.StatusCode}): {ex.Message}");
// Feed the central auth-failure tracker so the first-failure distress dispatches
// and the shutdown threshold advances. V1 parity: no retry on auth-failure —
// backend has definitively rejected the cert/token.
authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session");
return SessionRegistrationResult.AuthFailed(ex.StatusCode, ex.Message);
}
catch (Exception ex)
{
lastError = ex.Message;
logger.Error($"Failed to register session (attempt {attempt}/{MaxAttempts}): {ex.Message}", ex);
if (attempt == MaxAttempts)
{
try
{
if (emergencyReporter != null)
_ = emergencyReporter.TrySendAsync(
AgentErrorType.RegisterSessionFailed,
ex.Message);
}
catch { /* emergency channel is best-effort */ }
}
}
if (attempt < MaxAttempts)
{
var delaySeconds = (int)Math.Pow(2, attempt); // 2, 4, 8, 16
logger.Info($"Retrying session registration in {delaySeconds}s");
await backoffDelay(attempt).ConfigureAwait(false);
}
}
return SessionRegistrationResult.Failed(lastError ?? "max retries exceeded");
}
/// <summary>V1 parity exponential backoff: 2^attempt seconds.</summary>
private static Task DefaultBackoffDelay(int attempt)
=> Task.Delay(((int)Math.Pow(2, attempt)) * 1000);
private static SessionRegistration BuildRegistration(AgentConfiguration agentConfig, string agentVersion)
{
return new SessionRegistration
{
SessionId = agentConfig.SessionId,
TenantId = agentConfig.TenantId,
SerialNumber = DeviceInfoProvider.GetSerialNumber() ?? string.Empty,
Manufacturer = DeviceInfoProvider.GetManufacturer() ?? string.Empty,
Model = DeviceInfoProvider.GetModel() ?? string.Empty,
DeviceName = Environment.MachineName,
OsName = DeviceInfoProvider.GetOsName() ?? string.Empty,
OsBuild = DeviceInfoProvider.GetOsBuild() ?? string.Empty,
OsDisplayVersion = DeviceInfoProvider.GetOsDisplayVersion() ?? string.Empty,
OsEdition = DeviceInfoProvider.GetOsEdition() ?? string.Empty,
OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name ?? string.Empty,
StartedAt = DateTime.UtcNow,
AgentVersion = agentVersion ?? string.Empty,
EnrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(),
IsHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(),
IsUserDriven = true,
};
}
}
/// <summary>Classification of <see cref="SessionRegistrationHelper.RegisterWithRetryAsync"/>.</summary>
public enum SessionRegistrationOutcome
{
/// <summary>Backend responded with <c>Success=true</c>. The session row is live.</summary>
Succeeded = 0,
/// <summary>401/403 from the backend — cert/token rejected. No retry was attempted.</summary>
AuthFailed = 1,
/// <summary>All 5 attempts failed with non-auth errors (network, 5xx, malformed response).</summary>
Failed = 2,
}
/// <summary>Result of <see cref="SessionRegistrationHelper.RegisterWithRetryAsync"/>.</summary>
public sealed class SessionRegistrationResult
{
public SessionRegistrationOutcome Outcome { get; }
public RegisterSessionResponse Response { get; }
public int HttpStatusCode { get; }
public string ErrorMessage { get; }
public string AdminAction => Response?.AdminAction;
public ValidatorType ValidatedBy => Response?.ValidatedBy ?? ValidatorType.Unknown;
private SessionRegistrationResult(
SessionRegistrationOutcome outcome,
RegisterSessionResponse response,
int httpStatusCode,
string errorMessage)
{
Outcome = outcome;
Response = response;
HttpStatusCode = httpStatusCode;
ErrorMessage = errorMessage;
}
public static SessionRegistrationResult Succeeded(RegisterSessionResponse response)
=> new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null);
public static SessionRegistrationResult AuthFailed(int statusCode, string message)
=> new SessionRegistrationResult(SessionRegistrationOutcome.AuthFailed, null, statusCode, message);
public static SessionRegistrationResult Failed(string message)
=> new SessionRegistrationResult(SessionRegistrationOutcome.Failed, null, 0, message);
}
}
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@@ -8,8 +10,10 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Runtime;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Termination
{
@@ -17,28 +21,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
/// Orchestrates peripheral termination work in response to
/// <see cref="EnrollmentOrchestrator.Terminated"/>. Plan §4.x M4.6.β.
/// <para>
/// <b>Sequence</b> (Legacy parity — single-writer, best-effort each step, never throws):
/// <b>Sequence</b> (V1 parity — single-writer, best-effort each step, never throws):
/// </para>
/// <list type="number">
/// <item>Run shutdown analyzers (optional) so their delta events land before diagnostics.</item>
/// <item>Compose <see cref="FinalStatus"/> via <see cref="FinalStatusBuilder"/>.</item>
/// <item>Write final-status.json + launch SummaryDialog via <see cref="SummaryDialogLauncher"/>.</item>
/// <item>Upload diagnostics via <see cref="DiagnosticsPackageService"/> (respects
/// <c>DiagnosticsUploadMode</c>: Off / Always / OnFailure).</item>
/// <item>Write <c>enrollment-complete.marker</c> (so ghost-restart detection on next boot
/// exits cleanly even if <see cref="CleanupService"/> failed).</item>
/// <item>Write final-status.json + launch <see cref="SummaryDialogLauncher"/> if configured.</item>
/// <item>Emit <c>enrollment_summary_shown</c> event once the dialog has been handed to the user session.</item>
/// <item><see cref="Task.Delay(int)"/> 2s grace so late events can land before the next step.</item>
/// <item>Emit <c>diagnostics_collecting</c> → upload diagnostics → emit <c>diagnostics_uploaded</c> / <c>diagnostics_upload_failed</c>.</item>
/// <item>Write <c>enrollment-complete.marker</c> (ghost-restart guard on next boot).</item>
/// <item>Standalone-reboot path: if <c>RebootOnComplete &amp;&amp; !SelfDestructOnComplete</c> emit
/// <c>reboot_triggered</c>, drain spool, call <c>shutdown.exe /r /t &lt;delay&gt;</c>.</item>
/// <item>Run <see cref="CleanupService.ExecuteSelfDestruct"/> — UNLESS the stage is
/// <see cref="SessionStage.WhiteGloveSealed"/> (Part 1 exit, session resumes Part 2).</item>
/// <item>Signal the caller-owned shutdown <see cref="ManualResetEventSlim"/> so Program.cs'
/// main loop exits the <c>shutdown.Wait()</c> and calls <c>orchestrator.Stop()</c>.</item>
/// <see cref="SessionStage.WhiteGloveSealed"/> (Part-1 exit, session resumes Part 2).</item>
/// <item>WhiteGlove Part-1 path: emit <c>whiteglove_part1_complete</c>, drain spool, and
/// write <c>whiteglove.complete</c> marker via <see cref="SessionIdPersistence.SaveWhiteGloveComplete"/>
/// so Part-2 resume is detected on the next boot.</item>
/// <item>Signal the caller-owned shutdown <see cref="ManualResetEventSlim"/>.</item>
/// </list>
/// <para>
/// Each step logs + continues on failure — nothing in here is allowed to prevent the agent
/// from shutting down. CleanupService is fire-and-forget (spawns a PowerShell cleanup script
/// that waits for process exit); this handler does not block waiting for the script.
/// from shutting down. <see cref="CleanupService"/> is fire-and-forget (spawns a PowerShell
/// cleanup script that waits for process exit); this handler does not block waiting for it.
/// </para>
/// </summary>
public sealed class EnrollmentTerminationHandler
{
private static readonly TimeSpan DefaultLateEventGrace = TimeSpan.FromMilliseconds(2000);
private static readonly TimeSpan DefaultSpoolDrain = TimeSpan.FromMilliseconds(10000); // V1 parity: up to 20 × 500ms drains before shutdown.exe
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly string _stateDirectory;
@@ -50,6 +62,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
private readonly Action _signalShutdown;
private readonly string _dialogExePathOverride;
private readonly AgentAnalyzerManager _analyzerManager;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly SessionIdPersistence _sessionPersistence;
private readonly Action<int> _triggerReboot;
private readonly TimeSpan _lateEventGracePeriod;
private readonly TimeSpan _spoolDrainPeriod;
private int _handled;
@@ -64,7 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
Func<bool, string, Task<DiagnosticsUploadResult>> uploadDiagnosticsAsync,
Action signalShutdown,
string dialogExePathOverride = null,
AgentAnalyzerManager analyzerManager = null)
AgentAnalyzerManager analyzerManager = null,
Action<EnrollmentEvent> emitEvent = null,
SessionIdPersistence sessionPersistence = null,
Action<int> triggerReboot = null,
TimeSpan? lateEventGracePeriod = null,
TimeSpan? spoolDrainPeriod = null)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -77,6 +99,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
_signalShutdown = signalShutdown ?? throw new ArgumentNullException(nameof(signalShutdown));
_dialogExePathOverride = dialogExePathOverride;
_analyzerManager = analyzerManager;
_emitEvent = emitEvent;
_sessionPersistence = sessionPersistence;
_triggerReboot = triggerReboot ?? DefaultTriggerReboot;
_lateEventGracePeriod = lateEventGracePeriod ?? DefaultLateEventGrace;
_spoolDrainPeriod = spoolDrainPeriod ?? DefaultSpoolDrain;
}
/// <summary>
@@ -86,6 +113,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
{
if (Interlocked.Exchange(ref _handled, 1) == 1) return;
var isWhiteGlovePart1 = args.StageName == SessionStage.WhiteGloveSealed.ToString();
try
{
_logger.Info(
@@ -107,8 +136,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
RunBuildAndLaunchDialog(state, args);
}
RunUploadDiagnostics(args);
// WhiteGlove Part-1 exit: keep the session alive, but announce the handoff so
// the timeline clearly marks the transition. The `whiteglove.complete` marker
// lets the next agent boot classify itself as a Part-2 resume.
if (isWhiteGlovePart1)
{
EmitEventSafe(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "whiteglove_part1_complete",
Severity = EventSeverity.Info,
Source = "EnrollmentTerminationHandler",
Phase = EnrollmentPhase.Unknown,
Message = "WhiteGlove Part 1 complete — device will seal for end-user.",
ImmediateUpload = true,
});
DelayLateEventGrace();
DrainSpool();
TrySaveWhiteGloveComplete();
return;
}
DelayLateEventGrace();
RunUploadDiagnosticsWithEvents(args);
WriteEnrollmentCompleteMarker(args);
RunStandaloneRebootIfRequested();
RunSelfDestructIfAppropriate(args);
}
catch (Exception ex)
@@ -166,6 +222,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
var packages = TryGetPackageStates();
var status = FinalStatusBuilder.Build(state, args, packages, _agentStartTimeUtc);
SummaryDialogLauncher.WriteAndLaunch(status, _configuration, _stateDirectory, _logger, _dialogExePathOverride);
if (_configuration.ShowEnrollmentSummary)
{
EmitEventSafe(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "enrollment_summary_shown",
Severity = EventSeverity.Info,
Source = "EnrollmentTerminationHandler",
Phase = EnrollmentPhase.Unknown,
Message = "Enrollment summary dialog shown to user.",
Data = new Dictionary<string, object>
{
{ "totalApps", status?.AppSummary?.TotalApps ?? 0 },
{ "errorCount", status?.AppSummary?.ErrorCount ?? 0 },
{ "outcome", status?.Outcome ?? string.Empty },
{ "timeoutSeconds", _configuration.EnrollmentSummaryTimeoutSeconds },
},
ImmediateUpload = true,
});
}
}
catch (Exception ex)
{
@@ -173,7 +251,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
}
}
private void RunUploadDiagnostics(EnrollmentTerminatedEventArgs args)
private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args)
{
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
@@ -189,19 +267,75 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
return;
}
EmitEventSafe(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "diagnostics_collecting",
Severity = EventSeverity.Info,
Source = "EnrollmentTerminationHandler",
Phase = EnrollmentPhase.Unknown,
Message = "Collecting diagnostics package.",
Data = new Dictionary<string, object>
{
{ "mode", mode },
{ "enrollmentSucceeded", enrollmentSucceeded },
},
ImmediateUpload = true,
});
DiagnosticsUploadResult result = null;
try
{
var suffix = enrollmentSucceeded ? "success" : "failure";
var result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult();
if (result != null && result.Success)
_logger.Info($"EnrollmentTerminationHandler: diagnostics uploaded (blob={result.BlobName}).");
else
_logger.Warning($"EnrollmentTerminationHandler: diagnostics upload returned failure: {result?.ErrorCode ?? "null-result"}.");
result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTerminationHandler: diagnostics upload threw: {ex.Message}");
}
if (result != null && result.Success)
{
_logger.Info($"EnrollmentTerminationHandler: diagnostics uploaded (blob={result.BlobName}).");
EmitEventSafe(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "diagnostics_uploaded",
Severity = EventSeverity.Info,
Source = "EnrollmentTerminationHandler",
Phase = EnrollmentPhase.Unknown,
Message = $"Diagnostics package uploaded ({result.BlobName}).",
Data = new Dictionary<string, object>
{
{ "blobName", result.BlobName ?? string.Empty },
{ "sasUrlPrefix", result.SasUrlPrefix ?? string.Empty },
},
ImmediateUpload = true,
});
}
else
{
var errorCode = result?.ErrorCode ?? "null-result";
_logger.Warning($"EnrollmentTerminationHandler: diagnostics upload failed: {errorCode}.");
EmitEventSafe(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "diagnostics_upload_failed",
Severity = EventSeverity.Warning,
Source = "EnrollmentTerminationHandler",
Phase = EnrollmentPhase.Unknown,
Message = $"Diagnostics upload failed: {errorCode}.",
Data = new Dictionary<string, object>
{
{ "errorCode", errorCode },
{ "blobName", result?.BlobName ?? string.Empty },
},
ImmediateUpload = true,
});
}
}
private void WriteEnrollmentCompleteMarker(EnrollmentTerminatedEventArgs args)
@@ -228,6 +362,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
}
}
/// <summary>
/// V1 parity — standalone-reboot flow. When the tenant config disables self-destruct
/// but enables <c>RebootOnComplete</c>, the agent's final act is <c>shutdown.exe /r</c>
/// with the configured delay, giving the user a visible countdown.
/// </summary>
private void RunStandaloneRebootIfRequested()
{
if (_configuration.SelfDestructOnComplete) return;
if (!_configuration.RebootOnComplete) return;
var delay = _configuration.RebootDelaySeconds > 0 ? _configuration.RebootDelaySeconds : 10;
EmitEventSafe(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = "reboot_triggered",
Severity = EventSeverity.Info,
Source = "EnrollmentTerminationHandler",
Phase = EnrollmentPhase.Unknown,
Message = $"Standalone reboot triggered (delay={delay}s).",
Data = new Dictionary<string, object>
{
{ "rebootDelaySeconds", delay },
{ "selfDestructOnComplete", _configuration.SelfDestructOnComplete },
},
ImmediateUpload = true,
});
DrainSpool();
try
{
_triggerReboot(delay);
_logger.Info($"EnrollmentTerminationHandler: standalone reboot queued via shutdown.exe /r /t {delay}.");
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTerminationHandler: standalone reboot invocation failed: {ex.Message}");
}
}
private void RunSelfDestructIfAppropriate(EnrollmentTerminatedEventArgs args)
{
if (!_configuration.SelfDestructOnComplete)
@@ -253,5 +429,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
_logger.Error("EnrollmentTerminationHandler: cleanup service invocation threw.", ex);
}
}
private void TrySaveWhiteGloveComplete()
{
if (_sessionPersistence == null)
{
_logger.Warning("EnrollmentTerminationHandler: sessionPersistence not wired — whiteglove.complete marker NOT written (Part-2 detection will fail).");
return;
}
try { _sessionPersistence.SaveWhiteGloveComplete(_logger); }
catch (Exception ex) { _logger.Warning($"EnrollmentTerminationHandler: SaveWhiteGloveComplete threw: {ex.Message}"); }
}
private void DelayLateEventGrace()
{
if (_lateEventGracePeriod <= TimeSpan.Zero) return;
try { Task.Delay(_lateEventGracePeriod).Wait(); }
catch { /* best-effort */ }
}
private void DrainSpool()
{
// V1 parity — block briefly so pending events can land before the next destructive
// step (shutdown.exe, self-destruct). A precise HasPending/PendingCount signal is
// internal to the orchestrator transport, so the V2 equivalent is a bounded wait.
if (_spoolDrainPeriod <= TimeSpan.Zero) return;
try { Task.Delay(_spoolDrainPeriod).Wait(); }
catch { /* best-effort */ }
}
private void EmitEventSafe(EnrollmentEvent evt)
{
if (_emitEvent == null) return;
try { _emitEvent(evt); }
catch (Exception ex) { _logger.Debug($"EnrollmentTerminationHandler: event emission '{evt?.EventType}' threw: {ex.Message}"); }
}
private static void DefaultTriggerReboot(int delaySeconds)
{
var psi = new ProcessStartInfo
{
FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "shutdown.exe"),
Arguments = $"/r /t {delaySeconds} /c \"Autopilot enrollment completed - rebooting\"",
UseShellExecute = false,
CreateNoWindow = true,
};
Process.Start(psi);
}
}
}
+440 -11
View File
@@ -16,6 +16,8 @@ using AutopilotMonitor.Agent.V2.Core.Termination;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
@@ -206,12 +208,19 @@ namespace AutopilotMonitor.Agent.V2
// set by --install, no session.id yet) as a ghost restart and trigger
// ExecuteSelfDestruct before the remote-config fetch, deleting the ProgramData
// directory on every fresh deployment.
var sessionPersistence = new SessionIdPersistence(dataDirectory);
if (args.Contains("--new-session"))
{
new SessionIdPersistence(dataDirectory).Delete(logger);
sessionPersistence.Delete(logger);
logger.Info("--new-session: cleared persisted SessionId.");
}
agentConfig.SessionId = new SessionIdPersistence(dataDirectory).GetOrCreate(logger);
// Snapshot the WhiteGlove-resume state BEFORE GetOrCreate: on Part-2 resume we want
// to emit the whiteglove_resumed event AFTER orchestrator.Start. Reading the marker
// up front avoids racing any downstream clear. V1 parity — the Part-2 detection is
// marker-based, and the agent announces resume on the session timeline so that
// dashboards can correlate the session's two boots.
var isWhiteGloveResume = sessionPersistence.IsWhiteGloveResume();
agentConfig.SessionId = sessionPersistence.GetOrCreate(logger);
// Build a cleanup-service factory used by guards: instantiated lazily and with the
// current agentConfig so command-line overrides (e.g. --no-cleanup) take effect.
@@ -310,11 +319,14 @@ namespace AutopilotMonitor.Agent.V2
// defaults on AgentConfiguration; UpdateLimits is called after RemoteConfigMerger.Merge
// so tenant-policy overrides take effect. ThresholdExceeded is wired below, once the
// shutdown signal is available, to trigger a clean agent exit when the limit is hit.
// V1 parity — the distress reporter is plumbed in at construction so the tracker is
// the single dispatch point for auth-failure distress (first failure only).
var authFailureTracker = new AuthFailureTracker(
maxFailures: agentConfig.MaxAuthFailures,
timeoutMinutes: agentConfig.AuthFailureTimeoutMinutes,
clock: SystemClock.Instance,
logger: logger);
logger: logger,
distressReporter: distressReporter);
var remoteConfigService = new RemoteConfigService(
backendApiClient, agentConfig.TenantId, logger, emergencyReporter, distressReporter, authFailureTracker);
@@ -323,8 +335,10 @@ namespace AutopilotMonitor.Agent.V2
// Project remote tenant-controlled knobs onto the runtime AgentConfiguration so that
// downstream consumers (CleanupService, SummaryDialogLauncher, StartupEnvironmentProbes,
// DiagnosticsPackageService, EnrollmentTerminationHandler, the logger, the watchdog)
// actually respect the tenant admin settings. CLI flags stay authoritative over remote.
RemoteConfigMerger.Merge(agentConfig, remoteConfig, args);
// actually respect the tenant admin settings. V1 parity — remote wins
// unconditionally for every knob that has a 1:1 mapping. CLI flags seed the initial
// AgentConfiguration in BuildAgentConfiguration and then yield to tenant policy.
var configMergeResult = RemoteConfigMerger.Merge(agentConfig, remoteConfig, logger);
// Refresh tracker ceilings with the tenant-specific values we just merged in.
authFailureTracker.UpdateLimits(agentConfig.MaxAuthFailures, agentConfig.AuthFailureTimeoutMinutes);
@@ -338,10 +352,32 @@ namespace AutopilotMonitor.Agent.V2
SelfUpdater.BackendExpectedSha256 = remoteConfig.LatestAgentSha256;
// Post-config binary-integrity check: verify the running EXE's SHA-256 against the
// value advertised by the backend. Mismatch does not terminate (SelfUpdater's own
// hash-based trigger covers the "wrong EXE" case at next startup) — we just emit an
// IntegrityCheckFailed error report so operators see the drift.
var integrityResult = BinaryIntegrityVerifier.Check(remoteConfig.LatestAgentExeSha256, logger);
// value advertised by the backend. V1 parity — on mismatch we (a) emit an
// IntegrityCheckFailed emergency report and (b) fire the runtime self-update trigger
// so the agent auto-heals (SelfUpdater.CheckAndApplyUpdateAsync force-update path).
// The trigger is single-shot per process (Interlocked guard inside the verifier).
var agentDirForTrigger = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty;
Func<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(
@@ -395,6 +431,34 @@ namespace AutopilotMonitor.Agent.V2
return 5;
}
// V1 parity (MonitoringService.Start, MonitoringService.RegisterSessionAsync) —
// POST /api/agent/register-session with 5-retry exponential backoff (2s/4s/8s/16s)
// BEFORE the orchestrator starts. Without this call the backend's Sessions table
// never gets a row for this session, so IncrementSessionEventCountAsync /
// UpdateSessionStatusAsync silently no-op — events still land in the Events table
// but session status, phase, admin-overrides and validator reconcile all break.
// On failure we follow V1's rule: collectors MUST NOT start to prevent orphaned events.
var registrationResult = SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient: backendApiClient,
agentConfig: agentConfig,
agentVersion: GetAgentVersion(),
logger: logger,
authFailureTracker: authFailureTracker,
emergencyReporter: emergencyReporter).GetAwaiter().GetResult();
if (registrationResult.Outcome != SessionRegistrationOutcome.Succeeded)
{
logger.Error(
$"=== SESSION REGISTRATION FAILED ({registrationResult.Outcome}: {registrationResult.ErrorMessage}) — " +
"collectors will NOT start to prevent orphaned events. ===");
if (consoleMode)
Console.Error.WriteLine($"FATAL: session registration failed ({registrationResult.Outcome}). Agent exiting.");
try { mtlsHttpClient?.Dispose(); } catch { }
try { backendApiClient?.Dispose(); } catch { }
// Exit code differs so the diag skill can distinguish Auth vs Network in Scheduled-Task history.
return registrationResult.Outcome == SessionRegistrationOutcome.AuthFailed ? 6 : 7;
}
var classifiers = new IClassifier[]
{
new WhiteGloveSealingClassifier(),
@@ -461,7 +525,9 @@ namespace AutopilotMonitor.Agent.V2
cleanupServiceFactory: () => new CleanupService(agentConfig, logger),
uploadDiagnosticsAsync: uploadDiagnosticsAsync,
signalShutdown: () => shutdown.Set(),
analyzerManager: analyzerManager);
analyzerManager: analyzerManager,
emitEvent: evt => orchestrator.EventEmitter.Emit(evt),
sessionPersistence: sessionPersistence);
// ServerActionDispatcher — handlers are live and the M4.6.ε response-parse
// path below feeds it. RotateConfig refetches remote config;
@@ -522,14 +588,85 @@ namespace AutopilotMonitor.Agent.V2
Console.CancelKeyPress += cancelHandler;
AppDomain.CurrentDomain.ProcessExit += processExitHandler;
// V1 parity — on the max-lifetime watchdog firing, emit an explicit
// `enrollment_failed` event with failureType=agent_timeout BEFORE the
// regular termination path runs. Dashboards + KQL queries key on the
// event type + data dictionary to distinguish a genuine enrollment failure
// from a timeout-triggered shutdown.
EventHandler<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 +691,59 @@ namespace AutopilotMonitor.Agent.V2
// VersionCheckEventBuilder.TryBuild is a no-op when no markers are present.
EmitVersionCheckEventIfAny(orchestrator, agentConfig, logger);
// V1 parity (MonitoringService.AuditUnrestrictedModeChange) — whenever the
// remote tenant admin flips the UnrestrictedMode guardrail, emit an audit
// event so the change is visible in the session timeline and downstream
// alerting. Fires AFTER orchestrator.Start so the EventEmitter is alive.
EmitUnrestrictedModeAuditIfChanged(orchestrator, agentConfig, configMergeResult, logger);
// V1 parity — agent_started lifecycle event. Purely informational
// (Phase=Unknown); the session timeline and downstream alerting rely on
// it to classify the boot.
EmitAgentStartedEvent(orchestrator, agentConfig, previousExit, logger);
// WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start already posts
// SessionRecovered, which triggers HandleWhiteGlovePart1To2Bridge — that
// reducer effect emits whiteglove_resumed on the timeline. We only need
// to clear the persisted marker here.
if (isWhiteGloveResume)
{
try { sessionPersistence.ClearWhiteGloveComplete(logger); }
catch (Exception ex) { logger.Debug($"ClearWhiteGloveComplete threw: {ex.Message}"); }
}
// Reboot mid-session: post SystemRebootObserved so the reducer records
// the fact and emits the system_reboot_detected timeline entry.
if (string.Equals(previousExit?.ExitType, "reboot_kill", StringComparison.OrdinalIgnoreCase))
{
PostSystemRebootObservedSignal(orchestrator.IngressSink, previousExit, logger);
}
// V2 parity — post SessionStarted so the reducer establishes the session anchor
// (HandleSessionStartedV1 in DecisionEngine.Shared.cs). Skipped on:
// - WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start already posts
// SessionRecovered, which triggers HandleWhiteGlovePart1To2Bridge.
// - Admin preemption: the AdminPreemptionDetected signal below drives the
// session straight to a terminal stage; SessionStarted first would be noise.
if (!isWhiteGloveResume && string.IsNullOrEmpty(registrationResult.AdminAction))
{
PostSessionStartedSignal(orchestrator.IngressSink, registrationResult, agentConfig, logger);
}
// V1 parity (MonitoringService.cs:388-413 "ADMIN OVERRIDE on startup") —
// if the register-session response carried an AdminAction the operator
// has already marked the session terminal via the portal. Post
// AdminPreemptionDetected so the reducer transitions Stage to Completed /
// Failed and emits the enrollment_complete/_failed timeline event as a
// side effect. The orchestrator's DecisionStepProcessor picks up the
// terminal stage and raises Terminated, which runs the termination
// pipeline (cleanup + summary + self-destruct) through the subscribed
// handler — no direct synthesis needed.
if (!string.IsNullOrEmpty(registrationResult.AdminAction))
{
PostAdminPreemptionSignal(orchestrator.IngressSink, registrationResult, logger);
}
// M4.6.δ — fire-and-forget startup analyzers (LocalAdmin / SoftwareInventory /
// IntegrityBypass). Runs on a background task inside AgentAnalyzerManager.
try { analyzerManager.RunStartup(); }
@@ -586,6 +776,7 @@ namespace AutopilotMonitor.Agent.V2
Console.CancelKeyPress -= cancelHandler;
AppDomain.CurrentDomain.ProcessExit -= processExitHandler;
orchestrator.Terminated -= terminationHandler.Handle;
orchestrator.Terminated -= maxLifetimeEmitter;
authFailureTracker.ThresholdExceeded -= authThresholdHandler;
try { orchestrator.Stop(); }
@@ -671,6 +862,224 @@ namespace AutopilotMonitor.Agent.V2
};
}
/// <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>
/// V2 parity — post <see cref="DecisionSignalKind.AdminPreemptionDetected"/> when the
/// register-session response carried an <c>AdminAction</c>. The reducer handler at
/// DecisionEngine.Shared.cs#HandleAdminPreemptionDetectedV1 transitions Stage to the
/// terminal state and emits the enrollment_complete/_failed telemetry event; the
/// orchestrator's DecisionStepProcessor then raises the Terminated event, which the
/// subscribed <c>EnrollmentTerminationHandler</c> picks up — no direct synthesis needed.
/// </summary>
private static void PostAdminPreemptionSignal(
ISignalIngressSink ingressSink,
SessionRegistrationResult registrationResult,
AgentLogger logger)
{
try
{
var adminOutcome = registrationResult.AdminAction; // "Succeeded" | "Failed"
logger.Warning(
$"=== ADMIN OVERRIDE on startup: session already marked as {adminOutcome} by administrator — posting AdminPreemptionDetected signal ===");
ingressSink.Post(
kind: DecisionSignalKind.AdminPreemptionDetected,
occurredAtUtc: DateTime.UtcNow,
sourceOrigin: "register_session_response",
evidence: new Evidence(
kind: EvidenceKind.Synthetic,
identifier: $"admin_preemption:{adminOutcome}",
summary: $"Operator marked session as {adminOutcome} via portal before agent startup."),
payload: new System.Collections.Generic.Dictionary<string, string>(StringComparer.Ordinal)
{
["adminOutcome"] = adminOutcome,
});
}
catch (Exception ex)
{
logger.Warning($"AdminPreemptionDetected post failed: {ex.Message}");
}
}
/// <summary>
/// V2 parity — post a <see cref="DecisionSignalKind.SessionStarted"/> signal with the
/// tenant-registered session metadata (EnrollmentType / IsHybridJoin / ValidatedBy) so the
/// reducer's session-anchor handler runs. Without this the DecisionState.Stage stays at
/// the initial value and subsequent raw signals (ESP / Hello) see an uninitialised session.
/// </summary>
internal static void PostSessionStartedSignal(
ISignalIngressSink ingressSink,
SessionRegistrationResult registrationResult,
AgentConfiguration agentConfig,
AgentLogger logger)
{
try
{
var payload = new System.Collections.Generic.Dictionary<string, string>(StringComparer.Ordinal)
{
["enrollmentType"] = EnrollmentRegistryDetector.DetectEnrollmentType(),
["isHybridJoin"] = EnrollmentRegistryDetector.DetectHybridJoin() ? "true" : "false",
["validatedBy"] = registrationResult.ValidatedBy.ToString(),
["agentVersion"] = GetAgentVersion(),
["isBootstrapSession"] = agentConfig.UseBootstrapTokenAuth ? "true" : "false",
};
var evidence = new Evidence(
kind: EvidenceKind.Synthetic,
identifier: "register_session_success",
summary: "Session registration handshake succeeded; posting SessionStarted anchor for reducer.");
ingressSink.Post(
kind: DecisionSignalKind.SessionStarted,
occurredAtUtc: DateTime.UtcNow,
sourceOrigin: "Program.RunAgent",
evidence: evidence,
payload: payload);
logger.Debug($"SessionStarted signal posted (validatedBy={registrationResult.ValidatedBy}, enrollmentType={payload["enrollmentType"]}).");
}
catch (Exception ex)
{
logger.Warning($"SessionStarted post failed: {ex.Message}");
}
}
/// <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>
/// V2 parity — post <see cref="DecisionSignalKind.SystemRebootObserved"/> when the prior
/// agent process was terminated by an OS reboot. The reducer handler at
/// DecisionEngine.Edge.cs#HandleSystemRebootObservedV1 records the reboot fact on the
/// state (used by the WhiteGlove reboot-observed scoring weight, plan §2.4) and emits
/// the <c>system_reboot_detected</c> telemetry event as a side effect.
/// </summary>
private static void PostSystemRebootObservedSignal(
ISignalIngressSink ingressSink,
PreviousExitSummary previousExit,
AgentLogger logger)
{
try
{
var payload = new System.Collections.Generic.Dictionary<string, string>(StringComparer.Ordinal)
{
["previousExitType"] = previousExit?.ExitType ?? string.Empty,
["lastBootUtc"] = previousExit?.LastBootUtc?.ToString("o") ?? string.Empty,
};
ingressSink.Post(
kind: DecisionSignalKind.SystemRebootObserved,
occurredAtUtc: DateTime.UtcNow,
sourceOrigin: "Program.DetectPreviousExit",
evidence: new Evidence(
kind: EvidenceKind.Synthetic,
identifier: "previous_exit_reboot_kill",
summary: $"Prior agent process terminated by OS reboot (exitType={previousExit?.ExitType})."),
payload: payload);
}
catch (Exception ex)
{
logger.Warning($"SystemRebootObserved post failed: {ex.Message}");
}
}
private static void EmitVersionCheckEventIfAny(
EnrollmentOrchestrator orchestrator,
AgentConfiguration agentConfig,
@@ -704,7 +1113,7 @@ namespace AutopilotMonitor.Agent.V2
// ---------------------------------------------------------------- Configuration
private static AgentConfiguration BuildAgentConfiguration(
internal static AgentConfiguration BuildAgentConfiguration(
string[] args,
string tenantId,
string sessionId,
@@ -715,6 +1124,20 @@ namespace AutopilotMonitor.Agent.V2
var imeLogPathOverride = GetArgValue(args, "--ime-log-path");
var imeMatchLogPath = GetArgValue(args, "--ime-match-log");
// Dev / test — IME log replay (V1 compat mode). Feeds ImeLogTracker.SimulationMode
// + SpeedFactor so recorded raw IME logs are replayed at an accelerated rate; signal
// timestamps + ingress ordinals are regenerated as the replay runs.
var replayLogDir = GetArgValue(args, "--replay-log-dir");
var replaySpeedFactorRaw = GetArgValue(args, "--replay-speed-factor");
var replaySpeedFactor = 50.0;
if (!string.IsNullOrEmpty(replaySpeedFactorRaw)
&& double.TryParse(replaySpeedFactorRaw, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var parsedSpeed)
&& parsedSpeed > 0)
{
replaySpeedFactor = parsedSpeed;
}
var bootstrapToken = GetArgValue(args, "--bootstrap-token")
?? bootstrapConfig?.BootstrapToken;
@@ -760,6 +1183,8 @@ namespace AutopilotMonitor.Agent.V2
KeepLogFile = keepLogFile,
SelfDestructOnComplete = !noCleanup,
CommandLineArgs = FormatArgsForLog(args),
ReplayLogDir = replayLogDir,
ReplaySpeedFactor = replaySpeedFactor,
};
}
@@ -860,6 +1285,10 @@ namespace AutopilotMonitor.Agent.V2
Console.Out.WriteLine(" --api-url <URL> Override backend API base URL (alias: --backend-api)");
Console.Out.WriteLine(" --ime-log-path <PATH> Override IME logs directory");
Console.Out.WriteLine(" --ime-match-log <PATH> Write matched IME log lines to file (debug)");
Console.Out.WriteLine();
Console.Out.WriteLine("Dev / Test:");
Console.Out.WriteLine(" --replay-log-dir <PATH> Replay IME logs from this directory (simulation mode)");
Console.Out.WriteLine(" --replay-speed-factor <N> Time-compression factor for log replay (default: 50)");
}
private static void PrintVersion()
@@ -1 +1 @@
44
107
@@ -1 +1 @@
1071
1075
@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Plan V2-parity PR-B — the reducer is the single state owner, and telemetry events
/// for the three lifecycle signals (SystemRebootObserved, SessionRecovered→WG bridge,
/// AdminPreemptionDetected) appear as <see cref="DecisionEffectKind.EmitEventTimelineEntry"/>
/// effects rather than being synthesised by the V2 host.
/// </summary>
public sealed class ReducerTimelineEffectTests
{
private static DecisionSignal BuildSignal(
DecisionSignalKind kind,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, string>? payload = null,
string sourceOrigin = "test",
int ordinal = 1)
{
return new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: sourceOrigin,
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
payload: payload);
}
// ---------------------------------------------------------------- SystemRebootObserved
[Fact]
public void SystemRebootObservedV1_emits_system_reboot_detected_timeline_entry()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var payload = new Dictionary<string, string>
{
["previousExitType"] = "reboot_kill",
["lastBootUtc"] = "2026-04-22T08:00:00.0000000Z",
};
var signal = BuildSignal(
DecisionSignalKind.SystemRebootObserved,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
payload);
var step = engine.Reduce(state, signal);
Assert.NotNull(step.NewState.SystemRebootUtc);
var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry);
Assert.NotNull(timelineEffect.Parameters);
Assert.Equal("system_reboot_detected", timelineEffect.Parameters!["eventType"]);
Assert.Equal("reboot_kill", timelineEffect.Parameters["previousExitType"]);
Assert.Equal("2026-04-22T08:00:00.0000000Z", timelineEffect.Parameters["lastBootUtc"]);
}
[Fact]
public void SystemRebootObservedV1_without_payload_still_emits_timeline_entry()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var signal = BuildSignal(
DecisionSignalKind.SystemRebootObserved,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc));
var step = engine.Reduce(state, signal);
var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry);
Assert.Equal("system_reboot_detected", timelineEffect.Parameters!["eventType"]);
Assert.False(timelineEffect.Parameters.ContainsKey("previousExitType"));
}
// ------------------------------------------------------ WhiteGlovePart1To2 bridge
[Fact]
public void WhiteGlovePart1To2Bridge_emits_whiteglove_resumed_timeline_entry()
{
var engine = new DecisionEngine();
var sealedState = DecisionState.CreateInitial("s", "t")
.ToBuilder()
.WithStage(SessionStage.WhiteGloveSealed)
.Build();
var recoverSignal = BuildSignal(
DecisionSignalKind.SessionRecovered,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
sourceOrigin: "EnrollmentOrchestrator");
var step = engine.Reduce(sealedState, recoverSignal);
Assert.Equal(SessionStage.WhiteGloveAwaitingUserSignIn, step.NewState.Stage);
var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry);
Assert.Equal("whiteglove_resumed", timelineEffect.Parameters!["eventType"]);
Assert.Equal("2026-04-22T10:00:00.0000000Z", timelineEffect.Parameters["resumedAtUtc"]);
Assert.Equal("EnrollmentOrchestrator", timelineEffect.Parameters["sourceOrigin"]);
// ScheduleDeadline remains.
Assert.Contains(step.Effects, e => e.Kind == DecisionEffectKind.ScheduleDeadline);
}
// ---------------------------------------------------- AdminPreemptionDetected
[Fact]
public void AdminPreemptionDetectedV1_succeeded_transitions_to_completed_and_emits_complete_event()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var signal = BuildSignal(
DecisionSignalKind.AdminPreemptionDetected,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
new Dictionary<string, string> { ["adminOutcome"] = "Succeeded" },
sourceOrigin: "register_session_response");
var step = engine.Reduce(state, signal);
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome);
var effect = Assert.Single(step.Effects);
Assert.Equal(DecisionEffectKind.EmitEventTimelineEntry, effect.Kind);
Assert.Equal("enrollment_complete", effect.Parameters!["eventType"]);
Assert.Equal("Succeeded", effect.Parameters["adminAction"]);
Assert.Equal("register_session_response", effect.Parameters["source"]);
}
[Fact]
public void AdminPreemptionDetectedV1_failed_transitions_to_failed_and_emits_failed_event()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var signal = BuildSignal(
DecisionSignalKind.AdminPreemptionDetected,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
new Dictionary<string, string> { ["adminOutcome"] = "Failed" });
var step = engine.Reduce(state, signal);
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome);
var effect = Assert.Single(step.Effects);
Assert.Equal("enrollment_failed", effect.Parameters!["eventType"]);
Assert.Equal("Failed", effect.Parameters["adminAction"]);
}
[Fact]
public void AdminPreemptionDetectedV1_missing_outcome_defaults_to_failed()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var signal = BuildSignal(
DecisionSignalKind.AdminPreemptionDetected,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
payload: null);
var step = engine.Reduce(state, signal);
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome);
}
[Fact]
public void AdminPreemptionDetectedV1_clears_active_deadlines()
{
var engine = new DecisionEngine();
var stateWithDeadline = DecisionState.CreateInitial("s", "t")
.ToBuilder()
.AddDeadline(new ActiveDeadline(
name: DeadlineNames.ClassifierTick,
dueAtUtc: new DateTime(2026, 4, 22, 11, 0, 0, DateTimeKind.Utc),
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.ClassifierTick,
}))
.Build();
var signal = BuildSignal(
DecisionSignalKind.AdminPreemptionDetected,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
new Dictionary<string, string> { ["adminOutcome"] = "Succeeded" });
var step = engine.Reduce(stateWithDeadline, signal);
Assert.Empty(step.NewState.Deadlines);
}
}
}
@@ -86,7 +86,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>
/// Handle <see cref="DecisionSignalKind.SystemRebootObserved"/>. Records the fact so
/// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight.
/// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight, and emits
/// the <c>system_reboot_detected</c> timeline entry so the session timeline shows the
/// split between pre-reboot and post-reboot collection.
/// </summary>
private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal)
{
@@ -108,7 +110,27 @@ namespace AutopilotMonitor.DecisionCore.Engine
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.SystemRebootObserved));
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
var timelineParams = new Dictionary<string, string>
{
["eventType"] = "system_reboot_detected",
["reason"] = "prior agent process was terminated by the reboot",
};
if (signal.Payload != null)
{
if (signal.Payload.TryGetValue("previousExitType", out var previousExitType))
timelineParams["previousExitType"] = previousExitType ?? string.Empty;
if (signal.Payload.TryGetValue("lastBootUtc", out var lastBootUtc))
timelineParams["lastBootUtc"] = lastBootUtc ?? string.Empty;
}
var effects = new[]
{
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: timelineParams),
};
return new DecisionStep(newState, transition, effects);
}
}
}
@@ -139,6 +139,64 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
}
/// <summary>
/// Handle <see cref="DecisionSignalKind.AdminPreemptionDetected"/>. Plan §2.7 admin-
/// action audit; V2 parity PR-B3.
/// <para>
/// Emitted by <c>Program.RunAgent</c> when the register-session response carries an
/// <c>AdminAction</c> value (operator marked the session terminal via the portal before
/// the agent even started). The signal payload carries
/// <c>adminOutcome=Succeeded|Failed</c>.
/// </para>
/// <para>
/// Stage transitions to <see cref="SessionStage.Completed"/> (Succeeded) or
/// <see cref="SessionStage.Failed"/> (anything else); <see cref="SessionOutcome.AdminPreempted"/>
/// captures the non-enrollment nature of the transition so dashboards + KQL can tell
/// an admin-override apart from a genuine enrollment_complete/_failed.
/// </para>
/// </summary>
private DecisionStep HandleAdminPreemptionDetectedV1(DecisionState state, DecisionSignal signal)
{
var adminOutcome = signal.Payload != null && signal.Payload.TryGetValue("adminOutcome", out var v)
? v
: "Failed"; // defensive default: preemption without outcome is treated as failure.
var succeeded = string.Equals(adminOutcome, "Succeeded", StringComparison.OrdinalIgnoreCase);
var toStage = succeeded ? SessionStage.Completed : SessionStage.Failed;
var eventType = succeeded ? "enrollment_complete" : "enrollment_failed";
var nextStep = state.StepIndex + 1;
var newState = state.ToBuilder()
.WithStage(toStage)
.WithOutcome(SessionOutcome.AdminPreempted)
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.ClearDeadlines()
.Build();
var transition = BuildTakenTransition(
before: state,
signal: signal,
toStage: toStage,
nextStepIndex: nextStep,
trigger: $"AdminPreemption:{adminOutcome}");
var effects = new[]
{
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: new Dictionary<string, string>
{
["eventType"] = eventType,
["adminAction"] = adminOutcome,
["source"] = signal.SourceOrigin ?? "register_session_response",
["reason"] = $"Session {adminOutcome.ToLowerInvariant()} by administrator (detected on register-session).",
}),
};
return new DecisionStep(newState, transition, effects);
}
// ============================================================== shared helpers
// Partial-class shared helpers used by Classic / SelfDeploying / WhiteGlove handlers
// as they come online in M3.1+ live below. M3.0 establishes the skeleton; the bodies
@@ -51,6 +51,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
var effects = new[]
{
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: safety),
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: new Dictionary<string, string>
{
["eventType"] = "whiteglove_resumed",
["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o"),
["sourceOrigin"] = signal.SourceOrigin ?? string.Empty,
}),
};
return new DecisionStep(newState, transition, effects);
@@ -56,10 +56,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
return (signal.Kind, signal.KindSchemaVersion) switch
{
// ----- Lifecycle (DecisionEngine.Shared.cs) -----
(DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal),
(DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal),
(DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal),
(DecisionSignalKind.DeadlineFired, 1) => HandleDeadlineFiredV1(state, signal),
(DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal),
(DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal),
(DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal),
(DecisionSignalKind.AdminPreemptionDetected, 1) => HandleAdminPreemptionDetectedV1(state, signal),
(DecisionSignalKind.DeadlineFired, 1) => HandleDeadlineFiredV1(state, signal),
// ----- Classic UserDriven-v1 (DecisionEngine.Classic.cs) -----
(DecisionSignalKind.EspPhaseChanged, 1) => HandleEspPhaseChangedV1(state, signal),
@@ -41,5 +41,8 @@ namespace AutopilotMonitor.DecisionCore.Signals
SessionStarted,
SessionAborted,
SessionRecovered,
// --- Admin-driven preemption (Plan §2.7 admin-action audit, V2 parity PR-B3) ---
AdminPreemptionDetected,
}
}
@@ -13,5 +13,12 @@ namespace AutopilotMonitor.DecisionCore.State
WhiteGlovePart1Sealed,
WhiteGlovePart2Complete,
Aborted,
// V2 parity PR-B3 / plan §2.7 admin-action audit:
// Set when the register-session response carries an AdminAction
// (operator marked the session terminal via the portal before the
// agent even started). Stage transitions to Completed or Failed
// depending on the adminOutcome payload.
AdminPreempted,
}
}
@@ -0,0 +1,3 @@
# AdminPreemptionDetected v1 — adminOutcome=Failed → Stage.Failed,
# Outcome=AdminPreempted, effect emits enrollment_failed timeline entry.
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"AdminPreemptionDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"register_session_response","Evidence":{"Kind":"Synthetic","Identifier":"admin_preemption:Failed","Summary":"Operator marked session as Failed via portal before agent startup."},"Payload":{"adminOutcome":"Failed"}}
@@ -0,0 +1,4 @@
# AdminPreemptionDetected v1 — adminOutcome=Succeeded → Stage.Completed,
# Outcome=AdminPreempted, effect emits enrollment_complete timeline entry with
# adminAction=Succeeded + source=register_session_response.
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"AdminPreemptionDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"register_session_response","Evidence":{"Kind":"Synthetic","Identifier":"admin_preemption:Succeeded","Summary":"Operator marked session as Succeeded via portal before agent startup."},"Payload":{"adminOutcome":"Succeeded"}}
@@ -0,0 +1,5 @@
# SystemRebootObserved v1 — V2 parity PR-B2.
# Reducer handler HandleSystemRebootObservedV1 records SystemRebootUtc fact and emits
# system_reboot_detected timeline entry. Payload carries previousExitType + lastBootUtc
# so the backend can split the session into pre-/post-reboot segments.
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"SystemRebootObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"Program.DetectPreviousExit","Evidence":{"Kind":"Synthetic","Identifier":"previous_exit_reboot_kill","Summary":"Prior agent process terminated by OS reboot."},"Payload":{"previousExitType":"reboot_kill","lastBootUtc":"2026-04-22T08:00:00.0000000Z"}}