diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj
index 12b7e0d2..46dad5b2 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj
@@ -25,8 +25,10 @@
-
+
+
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorMaxLifetimeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorMaxLifetimeTests.cs
new file mode 100644
index 00000000..e7bd180d
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorMaxLifetimeTests.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
+using AutopilotMonitor.DecisionCore.Classifiers;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ ///
+ /// M4.6.α — max-lifetime watchdog. When the configured lifetime elapses without a
+ /// decision-terminal stage, the orchestrator raises
+ /// with reason and outcome
+ /// .
+ ///
+ [Collection("SerialThreading")]
+ public sealed class EnrollmentOrchestratorMaxLifetimeTests
+ {
+ private static DateTime At => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc);
+
+ private static EnrollmentOrchestrator Build(
+ TempDirectory tmp,
+ TimeSpan? agentMaxLifetime)
+ {
+ var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
+ return new EnrollmentOrchestrator(
+ sessionId: "S1",
+ tenantId: "T1",
+ stateDirectory: Path.Combine(tmp.Path, "State"),
+ transportDirectory: Path.Combine(tmp.Path, "Transport"),
+ clock: new VirtualClock(At),
+ logger: logger,
+ uploader: new FakeBackendTelemetryUploader(),
+ classifiers: new List(),
+ componentFactory: null,
+ drainInterval: TimeSpan.FromDays(1),
+ terminalDrainTimeout: TimeSpan.FromSeconds(2),
+ agentMaxLifetime: agentMaxLifetime);
+ }
+
+ [Fact]
+ public void Terminated_fires_once_when_max_lifetime_elapses()
+ {
+ using var tmp = new TempDirectory();
+ var sut = Build(tmp, agentMaxLifetime: TimeSpan.FromMilliseconds(200));
+
+ using var fired = new ManualResetEventSlim(false);
+ EnrollmentTerminatedEventArgs? captured = null;
+ var fireCount = 0;
+ sut.Terminated += (_, e) =>
+ {
+ Interlocked.Increment(ref fireCount);
+ captured = e;
+ fired.Set();
+ };
+
+ sut.Start();
+ Assert.True(fired.Wait(5000), "Terminated event did not fire within 5s.");
+
+ Assert.NotNull(captured);
+ Assert.Equal(EnrollmentTerminationReason.MaxLifetimeExceeded, captured!.Reason);
+ Assert.Equal(EnrollmentTerminationOutcome.TimedOut, captured.Outcome);
+ Assert.NotNull(captured.StageName);
+ Assert.NotNull(captured.Details);
+
+ // Even if the timer fired multiple times (it shouldn't — it's one-shot), we only
+ // raise the event once thanks to the Interlocked latch.
+ Thread.Sleep(300);
+ Assert.Equal(1, fireCount);
+
+ sut.Stop();
+ }
+
+ [Fact]
+ public void Terminated_not_fired_when_max_lifetime_is_null()
+ {
+ using var tmp = new TempDirectory();
+ var sut = Build(tmp, agentMaxLifetime: null);
+
+ var fireCount = 0;
+ sut.Terminated += (_, _) => Interlocked.Increment(ref fireCount);
+
+ sut.Start();
+ Thread.Sleep(200);
+ sut.Stop();
+
+ Assert.Equal(0, fireCount);
+ }
+
+ [Fact]
+ public void Ctor_rejects_non_positive_max_lifetime()
+ {
+ using var tmp = new TempDirectory();
+ Assert.Throws(() => Build(tmp, agentMaxLifetime: TimeSpan.Zero));
+ Assert.Throws(() => Build(tmp, agentMaxLifetime: TimeSpan.FromMilliseconds(-1)));
+ }
+
+ [Fact]
+ public void Stop_before_lifetime_elapses_silences_the_watchdog()
+ {
+ using var tmp = new TempDirectory();
+ var sut = Build(tmp, agentMaxLifetime: TimeSpan.FromSeconds(5));
+
+ var fireCount = 0;
+ sut.Terminated += (_, _) => Interlocked.Increment(ref fireCount);
+
+ sut.Start();
+ Thread.Sleep(50);
+ sut.Stop();
+
+ // Give any racing timer callback a chance — it must not fire after Stop().
+ Thread.Sleep(200);
+ Assert.Equal(0, fireCount);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs
new file mode 100644
index 00000000..3a0d2daa
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs
@@ -0,0 +1,244 @@
+using System;
+using System.IO;
+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 Newtonsoft.Json;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
+{
+ ///
+ /// Tests for the M4.6.α startup guards in Program.Guards.cs. These helpers are
+ /// internal static; the V2 exe exposes its internals to this test assembly via
+ /// [InternalsVisibleTo].
+ ///
+ public sealed class ProgramGuardsTests
+ {
+ private static DateTime ValidUtc => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc);
+
+ private static AgentLogger NewLogger(string path)
+ => new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
+
+ // ================================================================= DetectPreviousExit
+
+ [Fact]
+ public void DetectPreviousExit_reports_first_run_when_nothing_is_present()
+ {
+ using var tmp = new TempDirectory();
+ var summary = AutopilotMonitor.Agent.V2.Program.DetectPreviousExit(tmp.Path, Path.Combine(tmp.Path, "logs"));
+ Assert.Equal("first_run", summary.ExitType);
+ Assert.Null(summary.CrashExceptionType);
+ Assert.Null(summary.LastBootUtc);
+ }
+
+ [Fact]
+ public void DetectPreviousExit_reports_clean_when_marker_exists_and_deletes_it()
+ {
+ using var tmp = new TempDirectory();
+ var markerPath = Path.Combine(tmp.Path, "clean-exit.marker");
+ File.WriteAllText(markerPath, ValidUtc.ToString("O"));
+
+ var summary = AutopilotMonitor.Agent.V2.Program.DetectPreviousExit(tmp.Path, Path.Combine(tmp.Path, "logs"));
+
+ Assert.Equal("clean", summary.ExitType);
+ Assert.False(File.Exists(markerPath));
+ }
+
+ [Fact]
+ public void DetectPreviousExit_reports_exception_crash_and_extracts_exception_type()
+ {
+ using var tmp = new TempDirectory();
+ var logDir = Path.Combine(tmp.Path, "logs");
+ Directory.CreateDirectory(logDir);
+ var crashPath = Path.Combine(logDir, "crash_20260421_100000.log");
+ File.WriteAllText(crashPath, "[2026-04-21T10:00:00Z] FATAL: InvalidOperationException: simulated crash");
+
+ var summary = AutopilotMonitor.Agent.V2.Program.DetectPreviousExit(tmp.Path, logDir);
+
+ Assert.Equal("exception_crash", summary.ExitType);
+ Assert.Equal("InvalidOperationException", summary.CrashExceptionType);
+ Assert.False(File.Exists(crashPath));
+ }
+
+ [Fact]
+ public void DetectPreviousExit_reports_hard_kill_when_session_exists_without_marker()
+ {
+ using var tmp = new TempDirectory();
+ new SessionIdPersistence(tmp.Path).GetOrCreate();
+
+ var summary = AutopilotMonitor.Agent.V2.Program.DetectPreviousExit(tmp.Path, Path.Combine(tmp.Path, "logs"));
+
+ Assert.True(summary.ExitType == "hard_kill" || summary.ExitType == "reboot_kill",
+ $"Expected hard_kill or reboot_kill, got {summary.ExitType}.");
+ }
+
+ // ================================================================= Emergency break
+
+ [Fact]
+ public void CheckSessionAgeEmergencyBreak_returns_false_when_session_age_within_limit()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp.Path);
+ var persistence = new SessionIdPersistence(tmp.Path);
+ persistence.GetOrCreate();
+ persistence.SaveSessionCreatedAt(DateTime.UtcNow.AddHours(-1));
+
+ var tripped = AutopilotMonitor.Agent.V2.Program.CheckSessionAgeEmergencyBreak(
+ dataDirectory: tmp.Path,
+ stateDirectory: Path.Combine(tmp.Path, "State"),
+ absoluteMaxSessionHours: 48,
+ selfDestructOnComplete: false,
+ cleanupServiceFactory: null,
+ logger: logger,
+ consoleMode: false);
+
+ Assert.False(tripped);
+ }
+
+ [Fact]
+ public void CheckSessionAgeEmergencyBreak_trips_when_session_exceeds_limit()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp.Path);
+ var persistence = new SessionIdPersistence(tmp.Path);
+ persistence.GetOrCreate();
+ persistence.SaveSessionCreatedAt(DateTime.UtcNow.AddHours(-100));
+
+ var stateDir = Path.Combine(tmp.Path, "State");
+
+ var tripped = AutopilotMonitor.Agent.V2.Program.CheckSessionAgeEmergencyBreak(
+ dataDirectory: tmp.Path,
+ stateDirectory: stateDir,
+ absoluteMaxSessionHours: 48,
+ selfDestructOnComplete: false,
+ cleanupServiceFactory: null,
+ logger: logger,
+ consoleMode: false);
+
+ Assert.True(tripped);
+ // Marker must have been written so the next restart exits cleanly.
+ Assert.True(File.Exists(Path.Combine(stateDir, "enrollment-complete.marker")));
+ // Session must have been cleared.
+ Assert.False(persistence.SessionExists());
+ }
+
+ [Fact]
+ public void CheckSessionAgeEmergencyBreak_skips_whiteglove_resume_sessions()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp.Path);
+ var persistence = new SessionIdPersistence(tmp.Path);
+ persistence.GetOrCreate();
+ persistence.SaveSessionCreatedAt(DateTime.UtcNow.AddHours(-999));
+ File.WriteAllText(Path.Combine(tmp.Path, "whiteglove.complete"), "1");
+
+ var tripped = AutopilotMonitor.Agent.V2.Program.CheckSessionAgeEmergencyBreak(
+ dataDirectory: tmp.Path,
+ stateDirectory: Path.Combine(tmp.Path, "State"),
+ absoluteMaxSessionHours: 48,
+ selfDestructOnComplete: false,
+ cleanupServiceFactory: null,
+ logger: logger,
+ consoleMode: false);
+
+ Assert.False(tripped);
+ }
+
+ [Fact]
+ public void CheckSessionAgeEmergencyBreak_initialises_missing_session_created_on_first_miss()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp.Path);
+
+ // Session exists but session.created does not — simulates older persistence on upgrade.
+ File.WriteAllText(Path.Combine(tmp.Path, "session.id"), Guid.NewGuid().ToString());
+
+ var tripped = AutopilotMonitor.Agent.V2.Program.CheckSessionAgeEmergencyBreak(
+ dataDirectory: tmp.Path,
+ stateDirectory: Path.Combine(tmp.Path, "State"),
+ absoluteMaxSessionHours: 48,
+ selfDestructOnComplete: false,
+ cleanupServiceFactory: null,
+ logger: logger,
+ consoleMode: false);
+
+ Assert.False(tripped);
+ Assert.True(File.Exists(Path.Combine(tmp.Path, "session.created")));
+ }
+
+ // ================================================================= Bootstrap config IO
+
+ [Fact]
+ public void TryReadBootstrapConfig_returns_null_when_missing()
+ {
+ using var tmp = new TempDirectory();
+ Assert.Null(AutopilotMonitor.Agent.V2.Program.TryReadBootstrapConfig(tmp.Path, NewLogger(tmp.Path)));
+ }
+
+ [Fact]
+ public void TryReadBootstrapConfig_reads_tenant_and_token()
+ {
+ using var tmp = new TempDirectory();
+ var cfg = new BootstrapConfigFile { BootstrapToken = "tok-1", TenantId = "t-1" };
+ File.WriteAllText(Path.Combine(tmp.Path, "bootstrap-config.json"), JsonConvert.SerializeObject(cfg));
+
+ var read = AutopilotMonitor.Agent.V2.Program.TryReadBootstrapConfig(tmp.Path, NewLogger(tmp.Path));
+
+ Assert.NotNull(read);
+ Assert.Equal("tok-1", read!.BootstrapToken);
+ Assert.Equal("t-1", read.TenantId);
+ }
+
+ [Fact]
+ public void TryReadBootstrapConfig_returns_null_on_corrupt_json()
+ {
+ using var tmp = new TempDirectory();
+ File.WriteAllText(Path.Combine(tmp.Path, "bootstrap-config.json"), "{ bogus");
+ Assert.Null(AutopilotMonitor.Agent.V2.Program.TryReadBootstrapConfig(tmp.Path, NewLogger(tmp.Path)));
+ }
+
+ [Fact]
+ public void TryReadAwaitEnrollmentConfig_round_trips()
+ {
+ using var tmp = new TempDirectory();
+ var cfg = new AwaitEnrollmentConfigFile { TimeoutMinutes = 120 };
+ File.WriteAllText(Path.Combine(tmp.Path, "await-enrollment.json"), JsonConvert.SerializeObject(cfg));
+
+ var read = AutopilotMonitor.Agent.V2.Program.TryReadAwaitEnrollmentConfig(tmp.Path, NewLogger(tmp.Path));
+
+ Assert.NotNull(read);
+ Assert.Equal(120, read!.TimeoutMinutes);
+ }
+
+ [Fact]
+ public void DeleteAwaitEnrollmentConfig_removes_file()
+ {
+ using var tmp = new TempDirectory();
+ var path = Path.Combine(tmp.Path, "await-enrollment.json");
+ File.WriteAllText(path, "{}");
+
+ AutopilotMonitor.Agent.V2.Program.DeleteAwaitEnrollmentConfig(tmp.Path, NewLogger(tmp.Path));
+ Assert.False(File.Exists(path));
+ }
+
+ // ================================================================= Crash log writer
+
+ [Fact]
+ public void WriteCrashLog_writes_crash_file_with_fatal_prefix()
+ {
+ using var tmp = new TempDirectory();
+ var logDir = Path.Combine(tmp.Path, "logs");
+
+ AutopilotMonitor.Agent.V2.Program.WriteCrashLog(logDir, new InvalidOperationException("boom"));
+
+ var files = Directory.GetFiles(logDir, "crash_*.log");
+ Assert.Single(files);
+ var content = File.ReadAllText(files[0]);
+ Assert.Contains("FATAL:", content);
+ Assert.Contains("InvalidOperationException", content);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs
index 30d159bd..f677276d 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs
@@ -106,5 +106,109 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal(first, second);
}
+
+ // --------------------------------------------------------------- M4.6.α additions
+
+ [Fact]
+ public void GetOrCreate_also_writes_session_created_timestamp()
+ {
+ using var tmp = new TempDirectory();
+ var sut = new SessionIdPersistence(tmp.Path);
+
+ sut.GetOrCreate();
+
+ var createdPath = Path.Combine(tmp.Path, "session.created");
+ Assert.True(File.Exists(createdPath));
+ var parsed = sut.LoadSessionCreatedAt();
+ Assert.NotNull(parsed);
+ Assert.Equal(DateTimeKind.Utc, parsed!.Value.Kind);
+ }
+
+ [Fact]
+ public void LoadSessionCreatedAt_returns_null_when_file_missing()
+ {
+ using var tmp = new TempDirectory();
+ Assert.Null(new SessionIdPersistence(tmp.Path).LoadSessionCreatedAt());
+ }
+
+ [Fact]
+ public void LoadSessionCreatedAt_returns_null_when_file_is_corrupt()
+ {
+ using var tmp = new TempDirectory();
+ File.WriteAllText(Path.Combine(tmp.Path, "session.created"), "not-a-date");
+ Assert.Null(new SessionIdPersistence(tmp.Path).LoadSessionCreatedAt());
+ }
+
+ [Fact]
+ public void GetOrCreate_initialises_missing_session_created_on_recovery()
+ {
+ using var tmp = new TempDirectory();
+
+ // Simulate a session.id written by an older persistence without a companion timestamp.
+ var existingId = Guid.NewGuid().ToString();
+ File.WriteAllText(Path.Combine(tmp.Path, "session.id"), existingId);
+
+ var sut = new SessionIdPersistence(tmp.Path);
+ var resumed = sut.GetOrCreate();
+
+ Assert.Equal(existingId, resumed);
+ Assert.True(File.Exists(Path.Combine(tmp.Path, "session.created")));
+ Assert.NotNull(sut.LoadSessionCreatedAt());
+ }
+
+ [Fact]
+ public void SaveSessionCreatedAt_persists_utc_and_roundtrips()
+ {
+ using var tmp = new TempDirectory();
+ var sut = new SessionIdPersistence(tmp.Path);
+
+ var now = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc);
+ sut.SaveSessionCreatedAt(now);
+
+ var loaded = sut.LoadSessionCreatedAt();
+ Assert.Equal(now, loaded);
+ }
+
+ [Fact]
+ public void SessionExists_reflects_session_id_presence()
+ {
+ using var tmp = new TempDirectory();
+ var sut = new SessionIdPersistence(tmp.Path);
+
+ Assert.False(sut.SessionExists());
+
+ sut.GetOrCreate();
+ Assert.True(sut.SessionExists());
+
+ sut.Delete();
+ Assert.False(sut.SessionExists());
+ }
+
+ [Fact]
+ public void IsWhiteGloveResume_returns_true_when_marker_present()
+ {
+ using var tmp = new TempDirectory();
+ var sut = new SessionIdPersistence(tmp.Path);
+
+ Assert.False(sut.IsWhiteGloveResume());
+
+ File.WriteAllText(Path.Combine(tmp.Path, "whiteglove.complete"), "1");
+ Assert.True(sut.IsWhiteGloveResume());
+ }
+
+ [Fact]
+ public void Delete_clears_session_id_created_timestamp_and_whiteglove_marker()
+ {
+ using var tmp = new TempDirectory();
+ var sut = new SessionIdPersistence(tmp.Path);
+ sut.GetOrCreate();
+ File.WriteAllText(Path.Combine(tmp.Path, "whiteglove.complete"), "1");
+
+ sut.Delete();
+
+ Assert.False(File.Exists(Path.Combine(tmp.Path, "session.id")));
+ Assert.False(File.Exists(Path.Combine(tmp.Path, "session.created")));
+ Assert.False(File.Exists(Path.Combine(tmp.Path, "whiteglove.complete")));
+ }
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AwaitEnrollmentConfigFile.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AwaitEnrollmentConfigFile.cs
new file mode 100644
index 00000000..237b49ea
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AwaitEnrollmentConfigFile.cs
@@ -0,0 +1,16 @@
+namespace AutopilotMonitor.Agent.V2.Core.Configuration
+{
+ ///
+ /// Persisted await-enrollment configuration. Plan §4.x M4.6.α.
+ ///
+ /// Saved to %ProgramData%\AutopilotMonitor\await-enrollment.json by --install mode
+ /// so the Scheduled Task enters await-enrollment mode on startup (polls for the MDM certificate
+ /// before spinning up the DecisionEngine). Deleted once the MDM certificate is found — subsequent
+ /// restarts proceed normally.
+ ///
+ ///
+ public sealed class AwaitEnrollmentConfigFile
+ {
+ public int TimeoutMinutes { get; set; } = 480;
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BootstrapConfigFile.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BootstrapConfigFile.cs
new file mode 100644
index 00000000..6778b1cd
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BootstrapConfigFile.cs
@@ -0,0 +1,16 @@
+namespace AutopilotMonitor.Agent.V2.Core.Configuration
+{
+ ///
+ /// Persisted bootstrap configuration for OOBE-bootstrapped agents. Plan §4.x M4.6.α.
+ ///
+ /// Saved to %ProgramData%\AutopilotMonitor\bootstrap-config.json by --install mode
+ /// so the Scheduled Task (whose command line has no args) can pick up the bootstrap token and
+ /// tenant id on the first post-install run.
+ ///
+ ///
+ public sealed class BootstrapConfigFile
+ {
+ public string BootstrapToken { get; set; }
+ public string TenantId { get; set; }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs
index 0a48bcdb..b5dd44e0 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs
@@ -76,6 +76,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly int _quarantineThreshold;
private readonly TimeSpan _drainInterval;
private readonly TimeSpan _terminalDrainTimeout;
+ private readonly TimeSpan? _agentMaxLifetime;
+
+ // Max-lifetime watchdog (M4.6.α). Timer is armed in Start() when _agentMaxLifetime != null.
+ private System.Threading.Timer? _maxLifetimeTimer;
+ private int _terminatedFired;
// Built in Start()
private DecisionEngine? _engine;
@@ -131,7 +136,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
int channelCapacity = SignalIngress.DefaultChannelCapacity,
int quarantineThreshold = DecisionStepProcessor.DefaultQuarantineThreshold,
TimeSpan? drainInterval = null,
- TimeSpan? terminalDrainTimeout = null)
+ TimeSpan? terminalDrainTimeout = null,
+ TimeSpan? agentMaxLifetime = null)
{
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
@@ -161,8 +167,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_quarantineThreshold = quarantineThreshold;
_drainInterval = drainInterval ?? DefaultDrainInterval;
_terminalDrainTimeout = terminalDrainTimeout ?? DefaultTerminalDrainTimeout;
+
+ if (agentMaxLifetime.HasValue && agentMaxLifetime.Value <= TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(agentMaxLifetime), "AgentMaxLifetime must be positive when set.");
+ _agentMaxLifetime = agentMaxLifetime;
}
+ ///
+ /// Terminal event — fires once when the orchestrator declares the session done. Plan §4.x M4.6.α.
+ ///
+ /// M4.6.α fires only ; the
+ /// path is wired in M4.6.β
+ /// together with CleanupService self-destruct + SummaryDialog launch.
+ ///
+ ///
+ /// Handlers may run on a ThreadPool thread (Timer callback). They must NOT call
+ /// directly (re-entrant) — raise a shutdown
+ /// from the handler and let the main thread call .
+ ///
+ ///
+ public event EventHandler? Terminated;
+
// ---------------------------------------------------------------- Observability
/// Aktueller — nur nach verfügbar.
@@ -387,9 +412,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_drainCts = new CancellationTokenSource();
_drainTask = Task.Run(() => DrainLoopAsync(_drainCts.Token));
+ // 16) Max-lifetime watchdog (M4.6.α). Fires once after the configured duration when
+ // no real terminal stage has been reached. Timer is a best-effort System.Threading.Timer
+ // because VirtualClock-driven delay would not map to an OS-level wall-clock wait.
+ if (_agentMaxLifetime.HasValue)
+ {
+ _maxLifetimeTimer = new System.Threading.Timer(
+ state: null,
+ dueTime: _agentMaxLifetime.Value,
+ period: System.Threading.Timeout.InfiniteTimeSpan,
+ callback: _ => RaiseMaxLifetimeExceeded());
+ _logger.Info($"EnrollmentOrchestrator: max-lifetime watchdog armed ({_agentMaxLifetime.Value.TotalMinutes:F0}min).");
+ }
+
_logger.Info("EnrollmentOrchestrator: started.");
}
+ private void RaiseMaxLifetimeExceeded()
+ {
+ if (Volatile.Read(ref _stopRequested) == 1) return;
+ if (Interlocked.Exchange(ref _terminatedFired, 1) == 1) return;
+
+ _logger.Warning(
+ $"EnrollmentOrchestrator: max-lifetime ({_agentMaxLifetime?.TotalMinutes:F0}min) exceeded — firing Terminated(MaxLifetimeExceeded).");
+
+ var currentStage = _processor?.CurrentState?.Stage.ToString();
+
+ var terminatedArgs = new EnrollmentTerminatedEventArgs(
+ reason: EnrollmentTerminationReason.MaxLifetimeExceeded,
+ outcome: EnrollmentTerminationOutcome.TimedOut,
+ stageName: currentStage,
+ terminatedAtUtc: _clock.UtcNow,
+ details: $"Agent exceeded AgentMaxLifetimeMinutes cap ({_agentMaxLifetime?.TotalMinutes:F0}min) without reaching a terminal stage.");
+
+ try { Terminated?.Invoke(this, terminatedArgs); }
+ catch (Exception ex) { _logger.Error("EnrollmentOrchestrator: Terminated handler threw.", ex); }
+ }
+
///
/// Stop the pipeline. Idempotent; calls from as well.
///
@@ -400,6 +459,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_logger.Info("EnrollmentOrchestrator: stopping.");
+ // -1) Max-lifetime watchdog stoppen (M4.6.α). Idempotent — safe even if never armed.
+ try { _maxLifetimeTimer?.Dispose(); _maxLifetimeTimer = null; }
+ catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: max-lifetime timer dispose failed: {ex.Message}"); }
+
// 0) Collector-Hosts stoppen — keine neuen Events / DecisionSignals aus dem Feld.
if (_collectorHosts != null)
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentTerminatedEventArgs.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentTerminatedEventArgs.cs
new file mode 100644
index 00000000..744ee338
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentTerminatedEventArgs.cs
@@ -0,0 +1,77 @@
+#nullable enable
+using System;
+
+namespace AutopilotMonitor.Agent.V2.Core.Orchestration
+{
+ ///
+ /// Reasons for which fires. Plan §4.x M4.6.α.
+ ///
+ public enum EnrollmentTerminationReason
+ {
+ /// DecisionEngine reached a terminal stage via real signals (success or failure path).
+ DecisionTerminalStage,
+
+ ///
+ /// AgentMaxLifetimeMinutes safety watchdog fired — orchestrator has been running
+ /// longer than the configured cap without reaching a terminal stage.
+ ///
+ MaxLifetimeExceeded,
+ }
+
+ ///
+ /// Outcome descriptor emitted alongside .
+ ///
+ /// M4.6.β will consume this to drive CleanupService.ExecuteSelfDestruct,
+ /// SummaryDialog launch and DiagnosticsPackageService upload — the orchestrator
+ /// stays kernel-pure and only declares "we are done, here is the outcome".
+ ///
+ ///
+ public enum EnrollmentTerminationOutcome
+ {
+ ///
+ /// Reached a success stage (Completed / WhiteGloveSealed for Part 1 exit).
+ ///
+ Succeeded,
+
+ /// Reached an explicit failure stage.
+ Failed,
+
+ ///
+ /// Not yet terminal (used when the watchdog fires mid-flight — no classifier verdict,
+ /// stage is still an in-progress state).
+ ///
+ TimedOut,
+ }
+
+ ///
+ /// Payload for . Plan §4.x M4.6.α.
+ ///
+ /// Carries the decision-relevant fields needed by downstream peripheral capabilities
+ /// (M4.6.β: CleanupService + SummaryDialog + DiagnosticsPackageService) without forcing
+ /// them to touch the DecisionEngine state directly — that coupling would leak kernel
+ /// invariants into peripheral code.
+ ///
+ ///
+ public sealed class EnrollmentTerminatedEventArgs : EventArgs
+ {
+ public EnrollmentTerminationReason Reason { get; }
+ public EnrollmentTerminationOutcome Outcome { get; }
+ public string? StageName { get; }
+ public DateTime TerminatedAtUtc { get; }
+ public string? Details { get; }
+
+ public EnrollmentTerminatedEventArgs(
+ EnrollmentTerminationReason reason,
+ EnrollmentTerminationOutcome outcome,
+ string? stageName,
+ DateTime terminatedAtUtc,
+ string? details = null)
+ {
+ Reason = reason;
+ Outcome = outcome;
+ StageName = stageName;
+ TerminatedAtUtc = terminatedAtUtc;
+ Details = details;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs
index 831937df..e9fb5325 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs
@@ -20,6 +20,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public sealed class SessionIdPersistence
{
private readonly string _sessionFilePath;
+ private readonly string _sessionCreatedFilePath;
+ private readonly string _whiteGloveMarkerPath;
private readonly object _lockObject = new object();
public SessionIdPersistence(string dataDirectory)
@@ -31,11 +33,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
Directory.CreateDirectory(dataDirectory);
_sessionFilePath = Path.Combine(dataDirectory, "session.id");
+ _sessionCreatedFilePath = Path.Combine(dataDirectory, "session.created");
+ _whiteGloveMarkerPath = Path.Combine(dataDirectory, "whiteglove.complete");
}
///
/// Returns the persisted SessionId or creates, writes and returns a fresh one.
/// Thread-safe — concurrent callers see the same SessionId.
+ ///
+ /// Also initialises session.created on first creation (and on recovery when it is
+ /// missing) so the emergency-break watchdog can compare the session's wall-clock age.
+ ///
///
public string GetOrCreate(AgentLogger logger = null)
{
@@ -48,6 +56,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
var existing = File.ReadAllText(_sessionFilePath).Trim();
if (Guid.TryParse(existing, out _))
{
+ if (!File.Exists(_sessionCreatedFilePath))
+ {
+ // Recover session.created when session.id exists but the companion
+ // timestamp was lost (crash between the two writes, upgrade from an
+ // older SessionIdPersistence without session.created).
+ SaveSessionCreatedAtInternal(DateTime.UtcNow);
+ logger?.Info("SessionIdPersistence: initialised missing session.created for existing SessionId.");
+ }
logger?.Debug($"SessionIdPersistence: resumed existing SessionId={existing}.");
return existing;
}
@@ -61,27 +77,85 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
var fresh = Guid.NewGuid().ToString();
WriteAtomic(fresh, logger);
+ SaveSessionCreatedAtInternal(DateTime.UtcNow);
logger?.Info($"SessionIdPersistence: created new SessionId={fresh}.");
return fresh;
}
}
+ /// True when a persisted SessionId is present on disk.
+ public bool SessionExists()
+ {
+ lock (_lockObject) return File.Exists(_sessionFilePath);
+ }
+
+ ///
+ /// Returns the persisted session creation timestamp (UTC) or null when unavailable /
+ /// unparseable. Used by the M4.6.α emergency-break watchdog.
+ ///
+ public DateTime? LoadSessionCreatedAt()
+ {
+ lock (_lockObject)
+ {
+ try
+ {
+ if (!File.Exists(_sessionCreatedFilePath)) return null;
+ var raw = File.ReadAllText(_sessionCreatedFilePath).Trim();
+ if (DateTime.TryParse(raw, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
+ return parsed.Kind == DateTimeKind.Utc ? parsed : parsed.ToUniversalTime();
+ }
+ catch
+ {
+ // Fall through — treat corrupt file as missing so the watchdog does not trip on bogus data.
+ }
+ return null;
+ }
+ }
+
+ ///
+ /// Persists the session creation timestamp (UTC). Used by the watchdog to establish a
+ /// known-good baseline if the file is missing on recovery (see ).
+ ///
+ public void SaveSessionCreatedAt(DateTime createdAtUtc)
+ {
+ lock (_lockObject) SaveSessionCreatedAtInternal(createdAtUtc);
+ }
+
+ ///
+ /// true when a whiteglove.complete marker was persisted by the Part-1 exit path.
+ /// The watchdog treats these sessions as paused (device is powered off between Part 1 and
+ /// Part 2) and does NOT trip the emergency break on their age — the age clock restarts on
+ /// Part-2 resume.
+ ///
+ public bool IsWhiteGloveResume()
+ {
+ lock (_lockObject) return File.Exists(_whiteGloveMarkerPath);
+ }
+
/// Deletes the persisted SessionId. Next starts a fresh session.
public void Delete(AgentLogger logger = null)
{
lock (_lockObject)
{
- try
- {
- if (File.Exists(_sessionFilePath)) File.Delete(_sessionFilePath);
- }
- catch (Exception ex)
- {
- logger?.Warning($"SessionIdPersistence: failed to delete session.id: {ex.Message}");
- }
+ TryDelete(_sessionFilePath, logger);
+ TryDelete(_sessionCreatedFilePath, logger);
+ TryDelete(_whiteGloveMarkerPath, logger);
}
}
+ private static void TryDelete(string path, AgentLogger logger)
+ {
+ try { if (File.Exists(path)) File.Delete(path); }
+ catch (Exception ex) { logger?.Warning($"SessionIdPersistence: failed to delete {Path.GetFileName(path)}: {ex.Message}"); }
+ }
+
+ private void SaveSessionCreatedAtInternal(DateTime createdAtUtc)
+ {
+ if (createdAtUtc.Kind != DateTimeKind.Utc) createdAtUtc = createdAtUtc.ToUniversalTime();
+ try { File.WriteAllText(_sessionCreatedFilePath, createdAtUtc.ToString("O")); }
+ catch { /* best-effort — watchdog will fall back to initialising it on next start */ }
+ }
+
private void WriteAtomic(string sessionId, AgentLogger logger)
{
var tempPath = _sessionFilePath + ".tmp";
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/AssemblyInfo.cs b/src/Agent/AutopilotMonitor.Agent.V2/AssemblyInfo.cs
new file mode 100644
index 00000000..8fe41a0e
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2/AssemblyInfo.cs
@@ -0,0 +1,7 @@
+using System.Runtime.CompilerServices;
+
+// Internals are exposed to the V2.Core test project so it can cover the startup helpers
+// (Program.Guards.cs: DetectPreviousExit, CheckSessionAgeEmergencyBreak, TryReadBootstrapConfig,
+// TryReadAwaitEnrollmentConfig, WriteCrashLog, etc.) without duplicating the code into V2.Core.
+// Plan §4.x M4.6.α.
+[assembly: InternalsVisibleTo("AutopilotMonitor.Agent.V2.Core.Tests")]
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.Guards.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.Guards.cs
new file mode 100644
index 00000000..6057ca7f
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.Guards.cs
@@ -0,0 +1,416 @@
+using System;
+using System.Diagnostics;
+using System.Diagnostics.Eventing.Reader;
+using System.IO;
+using System.Linq;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
+using AutopilotMonitor.Agent.V2.Core.Security;
+using AutopilotMonitor.Shared;
+
+namespace AutopilotMonitor.Agent.V2
+{
+ ///
+ /// Startup guards ported from Legacy Program.cs. Plan §4.x M4.6.α.
+ ///
+ /// - Multi-instance guard — prevents a second agent process from running alongside
+ /// one that was started by the Scheduled Task.
+ /// - clean-exit.marker — written in so the next
+ /// start can distinguish clean shutdown from hard kill / reboot kill / exception crash.
+ /// - Previous-exit classification — reads the marker, crash logs and the system
+ /// event log (boot time 6009) and determines the previous exit type.
+ /// - Enrollment-complete marker check — ghost-restart detection: if a previous
+ /// session completed successfully but the Scheduled Task survived self-destruct, retry
+ /// cleanup and exit.
+ /// - Session-age emergency break — absolute cap (AbsoluteMaxSessionHours)
+ /// across restarts prevents zombie agents from persisting forever.
+ /// - Crash log writer — writes a last-resort crash_*.log next to the agent log
+ /// directory when an uncaught exception escapes Main.
+ ///
+ ///
+ public static partial class Program
+ {
+ internal const string CleanExitMarkerFileName = "clean-exit.marker";
+ internal const string EnrollmentCompleteMarkerFileName = "enrollment-complete.marker";
+ private const string CrashLogPrefix = "crash_";
+
+ // ----------------------------------------------------------------- Multi-instance guard
+
+ ///
+ /// true when another process with the same filename (module name) is alive.
+ /// Race-safe by design: we count processes sharing our name; if 1, only we exist.
+ ///
+ internal static bool IsAnotherAgentInstanceRunning()
+ {
+ try
+ {
+ var self = Process.GetCurrentProcess();
+ var siblings = Process.GetProcessesByName(self.ProcessName);
+ return siblings.Length > 1;
+ }
+ catch
+ {
+ // Without WMI or PerfCounter rights the probe will fail — assume no conflict so
+ // we do not block startup on a diagnostic false negative.
+ return false;
+ }
+ }
+
+ // ----------------------------------------------------------------- clean-exit marker
+
+ ///
+ /// Registers a handler that writes clean-exit.marker
+ /// as a last action so the next start can distinguish clean shutdown from hard kill.
+ /// Safe to call multiple times — duplicate handlers are filtered by delegate equality.
+ ///
+ internal static EventHandler RegisterCleanExitMarker(string dataDirectory)
+ {
+ var markerPath = Path.Combine(dataDirectory, CleanExitMarkerFileName);
+ EventHandler handler = (_, _) =>
+ {
+ try { File.WriteAllText(markerPath, DateTime.UtcNow.ToString("O")); } catch { }
+ };
+ AppDomain.CurrentDomain.ProcessExit += handler;
+ return handler;
+ }
+
+ // ----------------------------------------------------------------- Previous-exit detection
+
+ /// Lightweight bag returned by .
+ internal sealed class PreviousExitSummary
+ {
+ public string ExitType { get; internal set; } // "first_run" | "clean" | "exception_crash" | "hard_kill" | "reboot_kill"
+ public string CrashExceptionType { get; internal set; } // populated when ExitType == "exception_crash"
+ public DateTime? LastBootUtc { get; internal set; } // populated when ExitType == "reboot_kill"
+ }
+
+ ///
+ /// Reads clean-exit.marker + crash_*.log + session files + (if needed) the
+ /// Windows event log to classify the previous exit. Deletes the marker and crash logs so
+ /// the next cycle starts fresh. Legacy parity — same rules, same outputs.
+ ///
+ internal static PreviousExitSummary DetectPreviousExit(string dataDirectory, string logDirectory)
+ {
+ var summary = new PreviousExitSummary();
+
+ var cleanExitMarker = Path.Combine(dataDirectory, CleanExitMarkerFileName);
+ var hadCleanExit = File.Exists(cleanExitMarker);
+ var crashLogs = Directory.Exists(logDirectory)
+ ? Directory.GetFiles(logDirectory, CrashLogPrefix + "*.log")
+ : Array.Empty();
+
+ if (hadCleanExit)
+ {
+ summary.ExitType = "clean";
+ }
+ else if (crashLogs.Length > 0)
+ {
+ summary.ExitType = "exception_crash";
+ try
+ {
+ Array.Sort(crashLogs, StringComparer.Ordinal);
+ var mostRecent = crashLogs[crashLogs.Length - 1];
+ var crashContent = File.ReadAllText(mostRecent);
+ var fatalIdx = crashContent.IndexOf("FATAL: ", StringComparison.Ordinal);
+ if (fatalIdx >= 0)
+ {
+ var afterFatal = crashContent.Substring(fatalIdx + 7);
+ var colonIdx = afterFatal.IndexOf(':');
+ if (colonIdx > 0)
+ summary.CrashExceptionType = afterFatal.Substring(0, colonIdx).Trim();
+ }
+ }
+ catch { /* best-effort */ }
+ }
+ else
+ {
+ // No clean-exit marker and no crash log — either first run, hard kill or reboot kill.
+ var persistence = new SessionIdPersistence(dataDirectory);
+ if (persistence.SessionExists())
+ {
+ summary.ExitType = "hard_kill";
+ summary.LastBootUtc = GetLastBootTimeFromEventLog();
+ var sessionCreated = persistence.LoadSessionCreatedAt();
+ if (summary.LastBootUtc.HasValue && sessionCreated.HasValue
+ && summary.LastBootUtc.Value > sessionCreated.Value)
+ {
+ summary.ExitType = "reboot_kill";
+ }
+ }
+ else
+ {
+ summary.ExitType = "first_run";
+ }
+ }
+
+ // Clean up markers so the next cycle starts from zero.
+ try { File.Delete(cleanExitMarker); } catch { }
+ foreach (var crashLog in crashLogs)
+ try { File.Delete(crashLog); } catch { }
+
+ return summary;
+ }
+
+ ///
+ /// Queries the Windows System event log for the latest OS boot event (ID 6009). Returns
+ /// null when the query fails (e.g. on reduced-privilege runs in dev environments).
+ ///
+ internal static DateTime? GetLastBootTimeFromEventLog()
+ {
+ try
+ {
+ var query = new EventLogQuery(
+ "System",
+ PathType.LogName,
+ "*[System[Provider[@Name='EventLog'] and (EventID=6009)]]")
+ {
+ ReverseDirection = true,
+ };
+ using (var reader = new EventLogReader(query))
+ {
+ var record = reader.ReadEvent();
+ if (record?.TimeCreated != null)
+ {
+ using (record)
+ return record.TimeCreated.Value.ToUniversalTime();
+ }
+ }
+ }
+ catch { /* permission denied, event log service down — fall through */ }
+ return null;
+ }
+
+ // ----------------------------------------------------------------- Marker checks
+
+ ///
+ /// true when an enrollment-complete.marker or the Deployed registry
+ /// marker indicates that this device already completed enrollment. Legacy parity —
+ /// triggers a self-destruct retry and the agent exits cleanly.
+ ///
+ internal static bool CheckEnrollmentCompleteMarker(
+ string dataDirectory,
+ string stateDirectory,
+ bool selfDestructOnComplete,
+ Func cleanupServiceFactory,
+ AgentLogger logger,
+ bool consoleMode)
+ {
+ // Registry guard: "Deployed" key set by --install but no active session → ghost restart.
+ try
+ {
+ using (var regKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(DeploymentRegistryKey))
+ {
+ var deployed = regKey?.GetValue(DeploymentRegistryValue) as string;
+ if (!string.IsNullOrEmpty(deployed))
+ {
+ var persistence = new SessionIdPersistence(dataDirectory);
+ if (!persistence.SessionExists())
+ {
+ logger.Info($"Ghost restart detected (Deployed={deployed}, no active session).");
+ if (selfDestructOnComplete)
+ {
+ TryRetryCleanup(cleanupServiceFactory, logger, "ghost_restart");
+ }
+ if (consoleMode)
+ Console.Out.WriteLine("Agent was previously deployed but no active session. Exiting.");
+ return true;
+ }
+ logger.Info($"Deployment marker present ({deployed}) with active session — continuing normally.");
+ }
+ }
+ }
+ catch (Exception regEx)
+ {
+ logger.Warning($"Registry deployment marker check failed: {regEx.Message}");
+ }
+
+ // File-based enrollment-complete marker.
+ var markerPath = Path.Combine(stateDirectory, EnrollmentCompleteMarkerFileName);
+ if (!File.Exists(markerPath)) return false;
+
+ logger.Info("Enrollment-complete marker detected from a previous session.");
+
+ if (!selfDestructOnComplete)
+ {
+ if (consoleMode)
+ Console.Out.WriteLine("Enrollment already completed (no cleanup configured). Agent will exit.");
+ return true;
+ }
+
+ logger.Info("Enrollment already completed. Attempting self-destruct retry (scheduled task may have failed).");
+ if (consoleMode) Console.Out.WriteLine("Enrollment already completed. Attempting cleanup retry...");
+
+ TryRetryCleanup(cleanupServiceFactory, logger, "complete_marker");
+ return true;
+ }
+
+ ///
+ /// Emergency break: exits the agent if the current session has been alive longer than
+ /// AbsoluteMaxSessionHours. Respects WhiteGlove-paused sessions (the age clock
+ /// restarts on Part-2 resume). Legacy parity.
+ ///
+ internal static bool CheckSessionAgeEmergencyBreak(
+ string dataDirectory,
+ string stateDirectory,
+ int absoluteMaxSessionHours,
+ bool selfDestructOnComplete,
+ Func cleanupServiceFactory,
+ AgentLogger logger,
+ bool consoleMode)
+ {
+ try
+ {
+ var persistence = new SessionIdPersistence(dataDirectory);
+
+ if (persistence.IsWhiteGloveResume())
+ {
+ logger.Info("Emergency break: WhiteGlove resume detected — skipping session age check.");
+ return false;
+ }
+
+ var sessionCreatedAt = persistence.LoadSessionCreatedAt();
+ if (sessionCreatedAt == null)
+ {
+ if (persistence.SessionExists())
+ {
+ persistence.SaveSessionCreatedAt(DateTime.UtcNow);
+ logger.Info("Emergency break: initialised missing session.created for existing session.");
+ }
+ return false;
+ }
+
+ var sessionAgeHours = (DateTime.UtcNow - sessionCreatedAt.Value).TotalHours;
+ if (sessionAgeHours <= absoluteMaxSessionHours)
+ {
+ logger.Info($"Emergency break: session age {sessionAgeHours:F1}h within limit ({absoluteMaxSessionHours}h).");
+ return false;
+ }
+
+ logger.Warning($"EMERGENCY BREAK: session age {sessionAgeHours:F1}h exceeds maximum {absoluteMaxSessionHours}h — forcing cleanup.");
+ if (consoleMode)
+ Console.Out.WriteLine($"EMERGENCY: Session is {sessionAgeHours:F1}h old (max: {absoluteMaxSessionHours}h). Forcing cleanup.");
+
+ // Write enrollment-complete.marker so the next start exits cleanly even if
+ // the cleanup retry below fails (Scheduled Task still alive, files locked, …).
+ try
+ {
+ Directory.CreateDirectory(stateDirectory);
+ var markerPath = Path.Combine(stateDirectory, EnrollmentCompleteMarkerFileName);
+ File.WriteAllText(markerPath,
+ $"Emergency break at {DateTime.UtcNow:O} (session age: {sessionAgeHours:F1}h)");
+ }
+ catch (Exception ex) { logger.Warning($"Failed to write enrollment-complete.marker: {ex.Message}"); }
+
+ if (selfDestructOnComplete)
+ {
+ TryRetryCleanup(cleanupServiceFactory, logger, "emergency_break");
+ }
+
+ persistence.Delete(logger);
+ logger.Info("Emergency break: cleanup completed. Agent will exit.");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ logger.Error("Emergency break check failed.", ex);
+ return false;
+ }
+ }
+
+ private static void TryRetryCleanup(
+ Func cleanupServiceFactory,
+ AgentLogger logger,
+ string reason)
+ {
+ if (cleanupServiceFactory == null) return;
+ try
+ {
+ var service = cleanupServiceFactory();
+ service.ExecuteSelfDestruct();
+ logger.Info($"Cleanup retry ({reason}) completed.");
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"Cleanup retry ({reason}) failed: {ex.Message}");
+ }
+ }
+
+ // ----------------------------------------------------------------- Crash log
+
+ ///
+ /// Writes a last-resort crash_*.log next to the agent log directory when an uncaught
+ /// exception escapes . The next start reads this file and emits an
+ /// exception_crash previous-exit classification.
+ ///
+ internal static void WriteCrashLog(string logDirectory, Exception ex)
+ {
+ try
+ {
+ Directory.CreateDirectory(logDirectory);
+ var crashPath = Path.Combine(logDirectory, $"{CrashLogPrefix}{DateTime.UtcNow:yyyyMMdd_HHmmss}.log");
+ File.WriteAllText(crashPath, $"[{DateTime.UtcNow:u}] FATAL: {ex}");
+ }
+ catch { /* nowhere left to log */ }
+ }
+
+ // ----------------------------------------------------------------- Bootstrap-config read
+
+ ///
+ /// Reads a persisted bootstrap-config.json (if present) that was written by
+ /// --install. Returns null on miss or corrupt file.
+ ///
+ internal static BootstrapConfigFile TryReadBootstrapConfig(string dataDirectory, AgentLogger logger)
+ {
+ try
+ {
+ var path = Path.Combine(dataDirectory, BootstrapConfigFileName);
+ if (!File.Exists(path)) return null;
+ var json = File.ReadAllText(path);
+ return Newtonsoft.Json.JsonConvert.DeserializeObject(json);
+ }
+ catch (Exception ex)
+ {
+ logger?.Debug($"TryReadBootstrapConfig: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Reads a persisted await-enrollment.json (if present). Returns null on
+ /// miss or corrupt file.
+ ///
+ internal static AwaitEnrollmentConfigFile TryReadAwaitEnrollmentConfig(string dataDirectory, AgentLogger logger)
+ {
+ try
+ {
+ var path = Path.Combine(dataDirectory, AwaitEnrollmentConfigFileName);
+ if (!File.Exists(path)) return null;
+ var json = File.ReadAllText(path);
+ return Newtonsoft.Json.JsonConvert.DeserializeObject(json);
+ }
+ catch (Exception ex)
+ {
+ logger?.Debug($"TryReadAwaitEnrollmentConfig: {ex.Message}");
+ return null;
+ }
+ }
+
+ internal static void DeleteAwaitEnrollmentConfig(string dataDirectory, AgentLogger logger)
+ {
+ try
+ {
+ var path = Path.Combine(dataDirectory, AwaitEnrollmentConfigFileName);
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ logger?.Info("Await-enrollment: config file removed; subsequent restarts proceed normally.");
+ }
+ }
+ catch (Exception ex)
+ {
+ logger?.Warning($"Await-enrollment: could not remove config file: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs
new file mode 100644
index 00000000..0d44ca35
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs
@@ -0,0 +1,256 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Shared;
+using Microsoft.Win32;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.Agent.V2
+{
+ ///
+ /// --install mode: deploys the agent payload to the canonical install directory,
+ /// persists bootstrap and await-enrollment config for the Scheduled Task, registers + starts
+ /// the task, and writes the deployment registry marker. Plan §4.x M4.6.α.
+ ///
+ /// Ported 1:1 from Legacy Program.InstallMode.cs — the OOBE bootstrap script and the
+ /// Intune Platform Script contract expect exactly this sequence and these file locations.
+ /// The only delta is the V2 exe name / build-output directory.
+ ///
+ ///
+ public static partial class Program
+ {
+ internal const string DeploymentRegistryKey = @"SOFTWARE\AutopilotMonitor";
+ internal const string DeploymentRegistryValue = "Deployed";
+ internal const string BootstrapConfigFileName = "bootstrap-config.json";
+ internal const string AwaitEnrollmentConfigFileName = "await-enrollment.json";
+ private const string InstalledAgentExeName = "AutopilotMonitor.Agent.V2.exe";
+
+ internal static int RunInstallMode(string[] args)
+ {
+ var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
+ var logger = new AgentLogger(logDir, AgentLogLevel.Debug);
+ var consoleMode = args.Contains("--console") || Environment.UserInteractive;
+ logger.EnableConsoleOutput = consoleMode;
+
+ try
+ {
+ logger.Info("======================= Agent install mode (--install) =======================");
+
+ EnsureAgentDirectories(logger);
+
+ var sourceExePath = Assembly.GetExecutingAssembly().Location;
+ var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty;
+ var targetAgentDir = Environment.ExpandEnvironmentVariables(
+ Path.Combine(Constants.AgentDataDirectory, DefaultAgentSubdirectory));
+ var targetExePath = Path.Combine(targetAgentDir, InstalledAgentExeName);
+
+ if (!string.Equals(
+ Path.GetFullPath(sourceDir).TrimEnd('\\'),
+ Path.GetFullPath(targetAgentDir).TrimEnd('\\'),
+ StringComparison.OrdinalIgnoreCase))
+ {
+ logger.Info($"Install mode called from '{sourceDir}'. Deploying payload to '{targetAgentDir}'.");
+ CopyDirectory(sourceDir, targetAgentDir, logger);
+ }
+ else
+ {
+ logger.Info("Agent already running from target install directory; payload copy not required.");
+ }
+
+ // Persist bootstrap config if --bootstrap-token was provided. The Scheduled Task
+ // command line has no args — the agent picks this up on the first post-install run.
+ var bootstrapTokenArg = GetArgValue(args, "--bootstrap-token");
+ var tenantIdArg = GetArgValue(args, "--tenant-id");
+ if (!string.IsNullOrEmpty(bootstrapTokenArg))
+ {
+ var bootstrapConfigPath = Path.Combine(
+ Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory),
+ BootstrapConfigFileName);
+ var bootstrapConfig = new BootstrapConfigFile
+ {
+ BootstrapToken = bootstrapTokenArg,
+ TenantId = tenantIdArg,
+ };
+ File.WriteAllText(bootstrapConfigPath, JsonConvert.SerializeObject(bootstrapConfig));
+ logger.Info("Bootstrap config persisted for Scheduled Task.");
+ }
+
+ // Persist await-enrollment config if requested.
+ if (args.Contains("--await-enrollment"))
+ {
+ var awaitConfigPath = Path.Combine(
+ Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory),
+ AwaitEnrollmentConfigFileName);
+ var awaitTimeoutArg = GetArgValue(args, "--await-enrollment-timeout");
+ var timeoutMinutes = 480;
+ if (!string.IsNullOrEmpty(awaitTimeoutArg) && int.TryParse(awaitTimeoutArg, out var parsed))
+ timeoutMinutes = parsed;
+
+ var awaitConfig = new AwaitEnrollmentConfigFile { TimeoutMinutes = timeoutMinutes };
+ File.WriteAllText(awaitConfigPath, JsonConvert.SerializeObject(awaitConfig));
+ logger.Info($"Await-enrollment config persisted for Scheduled Task (timeout: {timeoutMinutes}min).");
+ }
+
+ var taskName = Constants.ScheduledTaskName;
+ var taskCommand = $"\"{targetExePath}\"";
+
+ logger.Info($"Registering Scheduled Task '{taskName}' for executable: {targetExePath}");
+
+ var createExitCode = RunProcess(
+ "schtasks.exe",
+ $"/Create /TN \"{taskName}\" /TR \"{taskCommand}\" /SC ONSTART /RU SYSTEM /RL HIGHEST /F",
+ logger);
+
+ if (createExitCode != 0)
+ throw new InvalidOperationException(
+ $"Failed to create/update Scheduled Task '{taskName}' (exit code {createExitCode}).");
+
+ logger.Info($"Scheduled Task '{taskName}' created/updated successfully.");
+
+ var startExitCode = RunProcess(
+ "schtasks.exe",
+ $"/Run /TN \"{taskName}\"",
+ logger);
+
+ if (startExitCode != 0)
+ throw new InvalidOperationException(
+ $"Failed to start Scheduled Task '{taskName}' (exit code {startExitCode}).");
+
+ logger.Info($"Scheduled Task '{taskName}' started successfully.");
+
+ TryWriteDeploymentMarker(logger);
+
+ if (consoleMode)
+ {
+ Console.WriteLine("Installation completed successfully.");
+ Console.WriteLine($"Task: {taskName}");
+ Console.WriteLine($"Executable: {targetExePath}");
+ Console.WriteLine($"Log: {logDir}");
+ }
+
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ logger.Error("Install mode failed.", ex);
+ if (consoleMode) Console.Error.WriteLine($"ERROR: {ex.Message}");
+ return 1;
+ }
+ }
+
+ private static void EnsureAgentDirectories(AgentLogger logger)
+ {
+ var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
+ var paths = new[]
+ {
+ basePath,
+ Path.Combine(basePath, DefaultAgentSubdirectory),
+ Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory),
+ Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
+ };
+
+ foreach (var path in paths)
+ {
+ if (!Directory.Exists(path))
+ {
+ Directory.CreateDirectory(path);
+ logger.Info($"Created directory: {path}");
+ }
+ else
+ {
+ logger.Debug($"Directory already exists: {path}");
+ }
+ }
+ }
+
+ private static int RunProcess(string fileName, string arguments, AgentLogger logger)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+
+ logger.Info($"Executing: {fileName} {arguments}");
+
+ using (var process = Process.Start(psi))
+ {
+ if (process == null)
+ throw new InvalidOperationException($"Failed to start process: {fileName}");
+
+ var stdout = process.StandardOutput.ReadToEnd();
+ var stderr = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+
+ if (!string.IsNullOrWhiteSpace(stdout)) logger.Info($"Process output: {stdout.Trim()}");
+ if (!string.IsNullOrWhiteSpace(stderr)) logger.Warning($"Process error output: {stderr.Trim()}");
+
+ logger.Info($"Process exit code: {process.ExitCode}");
+ return process.ExitCode;
+ }
+ }
+
+ private static void CopyDirectory(string sourceDir, string targetDir, AgentLogger logger)
+ {
+ if (!Directory.Exists(sourceDir))
+ throw new DirectoryNotFoundException($"Source directory not found: {sourceDir}");
+
+ Directory.CreateDirectory(targetDir);
+
+ foreach (var dir in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories))
+ {
+ var rel = dir.Substring(sourceDir.Length).TrimStart('\\');
+ Directory.CreateDirectory(Path.Combine(targetDir, rel));
+ }
+
+ foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
+ {
+ var rel = file.Substring(sourceDir.Length).TrimStart('\\');
+ var dest = Path.Combine(targetDir, rel);
+ Directory.CreateDirectory(Path.GetDirectoryName(dest) ?? targetDir);
+
+ try
+ {
+ File.Copy(file, dest, overwrite: true);
+ }
+ catch (Exception ex)
+ {
+ // Keep going on locked destinations — the Scheduled Task will pick up the
+ // latest available payload, and the runtime self-update path will retry later.
+ if (File.Exists(dest))
+ {
+ logger.Warning($"Could not overwrite '{dest}': {ex.Message}. Keeping existing file.");
+ continue;
+ }
+ throw;
+ }
+ }
+
+ logger.Info($"Payload deployment completed: '{sourceDir}' -> '{targetDir}'");
+ }
+
+ private static void TryWriteDeploymentMarker(AgentLogger logger)
+ {
+ try
+ {
+ using (var regKey = Registry.LocalMachine.CreateSubKey(DeploymentRegistryKey))
+ {
+ regKey.SetValue(DeploymentRegistryValue, DateTime.UtcNow.ToString("O"));
+ }
+ logger.Info($"Deployment registry marker written (HKLM\\{DeploymentRegistryKey}\\{DeploymentRegistryValue}).");
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"Failed to write deployment registry marker: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
index be408144..5dad498f 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
@@ -6,6 +6,7 @@ using System.Reflection;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
@@ -13,36 +14,48 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2
{
///
- /// V2-Agent entry point. Plan §4.x M4.5.b.
+ /// V2-Agent entry point. Plan §4.x M4.5.b + M4.6.α.
///
- /// Start sequence:
+ /// Boot sequence (same order as Legacy for feature parity):
///
///
- /// - Parse CLI args (--help, --version, --console, --new-session,
- /// --api-url, --bootstrap-token, --await-enrollment, --ime-log-path,
- /// --ime-match-log)
- /// - Initialise on %ProgramData%\AutopilotMonitor\Logs
- /// - Resolve TenantId via
- /// - Load/create SessionId via
- /// - (Optional) Wait for MDM certificate in await-enrollment mode
- /// - Build mTLS- via
- /// - Fetch remote config via +
- /// - Wire for telemetry batch uploads
- /// - Construct +
- /// - orchestrator.Start() and wait for Ctrl+C /
- /// - orchestrator.Stop(), return 0
+ /// - --help / --version short-circuit
+ /// - --install forks to and exits
+ /// - Multi-instance guard — single-agent invariant
+ /// - Register ProcessExit → writes clean-exit.marker
+ /// - Ensure agent directories exist
+ /// - +
+ /// - Load cached remote-config.json → + AllowAgentDowngrade
+ /// - — on success restarts the process; on failure continues with current binary
+ /// - — reads markers + event log to classify last shutdown
+ /// - — ghost-restart detection + cleanup retry
+ /// - — absolute session-age watchdog
+ /// - Resolve TenantId (registry → bootstrap-config.json fallback)
+ /// - Build (CLI args + persisted bootstrap / await-enrollment config)
+ /// - Get/create SessionId via
+ /// - (Optional) Wait for MDM certificate in --await-enrollment mode
+ /// - Build + → fetch config
+ /// - — H-2 mitigation post-cert
+ /// - Build mTLS →
+ /// - Build +
+ /// - orchestrator.Start() → emit -derived event
+ /// - Wait for Ctrl+C / ProcessExit / EnrollmentTerminated
+ /// - orchestrator.Stop(), exit 0
///
///
- public static class Program
+ public static partial class Program
{
private const string DefaultStateDirectory = @"%ProgramData%\AutopilotMonitor";
private const string DefaultLogDirectory = @"%ProgramData%\AutopilotMonitor\Logs";
+ private const string DefaultAgentSubdirectory = "Agent";
private const string DefaultV2StateSubdirectory = "V2";
private const string DefaultTransportSubdirectory = "V2Transport";
+ private const string CachedRemoteConfigPath = @"%ProgramData%\AutopilotMonitor\Config\remote-config.json";
public static int Main(string[] args)
{
@@ -58,21 +71,79 @@ namespace AutopilotMonitor.Agent.V2
return 0;
}
+ // --install forks to a separate flow that exits when done (never falls through into RunAgent).
+ if (args.Contains("--install"))
+ {
+ return RunInstallMode(args);
+ }
+
var consoleMode = args.Contains("--console") || Environment.UserInteractive;
+ // Multi-instance guard — prevents a second agent process from running alongside one
+ // that was started by the Scheduled Task.
+ if (IsAnotherAgentInstanceRunning())
+ {
+ var msg = "Another agent process is already running. This instance will exit.";
+ if (consoleMode) Console.Error.WriteLine($"ERROR: {msg}");
+ try
+ {
+ var earlyLogger = new AgentLogger(Environment.ExpandEnvironmentVariables(DefaultLogDirectory));
+ earlyLogger.Warning(msg);
+ }
+ catch { /* best-effort */ }
+ return 1;
+ }
+
var dataDirectory = Environment.ExpandEnvironmentVariables(DefaultStateDirectory);
var logDirectory = Environment.ExpandEnvironmentVariables(DefaultLogDirectory);
- var logger = new AgentLogger(logDirectory) { EnableConsoleOutput = consoleMode };
- logger.Info($"AutopilotMonitor.Agent.V2 starting (version {GetAgentVersion()}).");
+ try { Directory.CreateDirectory(dataDirectory); } catch { }
+ try { Directory.CreateDirectory(logDirectory); } catch { }
+
+ // Register the clean-exit marker writer BEFORE any risky startup work so an OS
+ // shutdown during self-update still produces a "clean" classification.
+ RegisterCleanExitMarker(dataDirectory);
+
+ // Startup self-update. Legacy parity: cleanup leftover .old files, load cached
+ // backend hash / downgrade policy, then attempt the update. Failures here never
+ // abort startup — we prefer to run the current version than delay.
+ SelfUpdater.LogInit(GetAgentVersion());
+
+ var agentDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty;
+ SelfUpdater.CleanupPreviousUpdate(agentDir, msg => { if (consoleMode) Console.Out.WriteLine(msg); });
+
+ var allowAgentDowngrade = LoadCachedSelfUpdateContext();
try
{
- return RunAgent(args, logger, dataDirectory, consoleMode);
+ SelfUpdater.CheckAndApplyUpdateAsync(
+ currentVersion: GetAgentVersion(),
+ agentDir: agentDir,
+ consoleMode: consoleMode,
+ allowDowngrade: allowAgentDowngrade).GetAwaiter().GetResult();
+ }
+ catch (Exception selfUpdateEx)
+ {
+ // SelfUpdater is designed to swallow its own errors; catch anything that still
+ // escapes so startup does not abort on an unexpected failure in the update path.
+ SelfUpdater.Log($"Self-update outer exception: {selfUpdateEx.Message}");
+ }
+
+ // At this point either (a) no update was applied, or (b) update applied but restart
+ // didn't happen — continue startup with the current binary.
+
+ var logger = new AgentLogger(logDirectory) { EnableConsoleOutput = consoleMode };
+ logger.Info($"AutopilotMonitor.Agent.V2 starting (version {GetAgentVersion()}).");
+ logger.Info($"Command line: {FormatArgsForLog(args)}");
+
+ try
+ {
+ return RunAgent(args, logger, dataDirectory, logDirectory, consoleMode);
}
catch (Exception ex)
{
logger.Error("V2 agent startup failed.", ex);
+ WriteCrashLog(logDirectory, ex);
if (consoleMode) Console.Error.WriteLine($"FATAL: {ex.Message}");
return 1;
}
@@ -80,26 +151,68 @@ namespace AutopilotMonitor.Agent.V2
// ---------------------------------------------------------------- Orchestration
- private static int RunAgent(string[] args, AgentLogger logger, string dataDirectory, bool consoleMode)
+ private static int RunAgent(
+ string[] args,
+ AgentLogger logger,
+ string dataDirectory,
+ string logDirectory,
+ bool consoleMode)
{
- if (!Directory.Exists(dataDirectory)) Directory.CreateDirectory(dataDirectory);
+ var stateSubdir = Path.Combine(dataDirectory, DefaultV2StateSubdirectory);
+ var transportDir = Path.Combine(dataDirectory, DefaultTransportSubdirectory);
- var tenantId = TenantIdResolver.ResolveFromEnrollmentRegistry(logger);
- if (string.IsNullOrEmpty(tenantId))
+ // Previous-exit classification (for the agent_started event + observability).
+ var previousExit = DetectPreviousExit(dataDirectory, logDirectory);
+ if (previousExit.ExitType != "first_run")
{
- logger.Error("V2 agent cannot start: TenantId could not be resolved from the enrollment registry. Device is not MDM-enrolled.");
- return 2;
+ var crashSuffix = previousExit.CrashExceptionType != null ? $" ({previousExit.CrashExceptionType})" : "";
+ logger.Info($"Previous exit: {previousExit.ExitType}{crashSuffix}");
}
+ // Merge persisted bootstrap-config.json + await-enrollment.json into CLI args early.
+ var bootstrapConfig = TryReadBootstrapConfig(dataDirectory, logger);
+ var awaitConfig = TryReadAwaitEnrollmentConfig(dataDirectory, logger);
+
+ var tenantIdFromRegistry = TenantIdResolver.ResolveFromEnrollmentRegistry(logger);
+ var tenantId = !string.IsNullOrEmpty(tenantIdFromRegistry)
+ ? tenantIdFromRegistry
+ : bootstrapConfig?.TenantId;
+
+ // --install time may have written a bootstrap config that holds a tenantId even when
+ // the registry is not yet populated (bootstrap token path). Honour that.
+
if (args.Contains("--new-session"))
{
new SessionIdPersistence(dataDirectory).Delete(logger);
logger.Info("--new-session: cleared persisted SessionId.");
}
- var sessionId = new SessionIdPersistence(dataDirectory).GetOrCreate(logger);
+ var agentConfig = BuildAgentConfiguration(args, tenantId, sessionId: null, bootstrapConfig, awaitConfig);
- var agentConfig = BuildAgentConfiguration(args, tenantId, sessionId);
+ // 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.
+ Func cleanupServiceFactory = () => new CleanupService(agentConfig, logger);
+
+ if (CheckEnrollmentCompleteMarker(
+ dataDirectory, stateSubdir,
+ agentConfig.SelfDestructOnComplete, cleanupServiceFactory, logger, consoleMode))
+ {
+ logger.Info("Enrollment-complete marker handled — agent exiting.");
+ return 0;
+ }
+
+ if (CheckSessionAgeEmergencyBreak(
+ dataDirectory, stateSubdir,
+ agentConfig.AbsoluteMaxSessionHours, agentConfig.SelfDestructOnComplete,
+ cleanupServiceFactory, logger, consoleMode))
+ {
+ logger.Info("Emergency break fired — agent exiting.");
+ return 0;
+ }
+
+ // Session is safe to start — create/recover SessionId now (AFTER guards so a dead
+ // session is not resurrected before the watchdog can kill it).
+ agentConfig.SessionId = new SessionIdPersistence(dataDirectory).GetOrCreate(logger);
if (agentConfig.AwaitEnrollment)
{
@@ -119,6 +232,23 @@ namespace AutopilotMonitor.Agent.V2
return 3;
}
}
+
+ // Re-resolve TenantId — enrollment typically writes the registry key alongside the cert.
+ if (string.IsNullOrEmpty(agentConfig.TenantId))
+ {
+ agentConfig.TenantId = TenantIdResolver.ResolveFromEnrollmentRegistry(logger);
+ if (!string.IsNullOrEmpty(agentConfig.TenantId))
+ logger.Info($"Await-enrollment: TenantId discovered from registry: {agentConfig.TenantId}");
+ }
+
+ // Await-enrollment is one-shot — remove the persisted config so subsequent restarts proceed normally.
+ DeleteAwaitEnrollmentConfig(dataDirectory, logger);
+ }
+
+ if (string.IsNullOrEmpty(agentConfig.TenantId))
+ {
+ logger.Error("V2 agent cannot start: TenantId not available (registry empty + no bootstrap config).");
+ return 2;
}
var backendApiClient = new BackendApiClient(
@@ -127,11 +257,30 @@ namespace AutopilotMonitor.Agent.V2
logger: logger,
agentVersion: GetAgentVersion());
- var remoteConfigService = new RemoteConfigService(backendApiClient, tenantId, logger);
+ var remoteConfigService = new RemoteConfigService(backendApiClient, agentConfig.TenantId, logger);
var remoteConfig = remoteConfigService.FetchConfigAsync().GetAwaiter().GetResult();
logger.SetLogLevel(agentConfig.LogLevel);
+ // Propagate the backend-expected SHA so the runtime hash-mismatch trigger (M4.6.α
+ // continues this wire; actual runtime trigger will be wired via ServerActionDispatcher
+ // in M4.6.β) has the up-to-date integrity hash. Also refresh AllowAgentDowngrade.
+ if (!string.IsNullOrEmpty(remoteConfig.LatestAgentSha256))
+ SelfUpdater.BackendExpectedSha256 = remoteConfig.LatestAgentSha256;
+
+ // H-2 mitigation: delete the persisted bootstrap-config.json once the MDM cert
+ // proves it can authenticate. Non-blocking — any failure leaves the file for retry.
+ try
+ {
+ BootstrapConfigCleanup
+ .TryDeleteIfCertReadyAsync(agentConfig, logger, GetAgentVersion())
+ .GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ logger.Debug($"BootstrapConfigCleanup outer exception: {ex.Message}");
+ }
+
HttpClient mtlsHttpClient;
BackendTelemetryUploader uploader;
try
@@ -152,7 +301,7 @@ namespace AutopilotMonitor.Agent.V2
uploader = new BackendTelemetryUploader(
httpClient: mtlsHttpClient,
baseUrl: agentConfig.ApiBaseUrl,
- tenantId: tenantId,
+ tenantId: agentConfig.TenantId,
manufacturer: hardware.Manufacturer,
model: hardware.Model,
serialNumber: hardware.SerialNumber,
@@ -165,9 +314,6 @@ namespace AutopilotMonitor.Agent.V2
return 5;
}
- var stateSubdir = Path.Combine(dataDirectory, DefaultV2StateSubdirectory);
- var transportDir = Path.Combine(dataDirectory, DefaultTransportSubdirectory);
-
var classifiers = new IClassifier[]
{
new WhiteGloveSealingClassifier(),
@@ -184,9 +330,13 @@ namespace AutopilotMonitor.Agent.V2
var whiteGloveSealingPatternIds = (System.Collections.Generic.IReadOnlyCollection)remoteConfig.WhiteGloveSealingPatternIds
?? Array.Empty();
+ var agentMaxLifetime = agentConfig.AgentMaxLifetimeMinutes > 0
+ ? (TimeSpan?)TimeSpan.FromMinutes(agentConfig.AgentMaxLifetimeMinutes)
+ : null;
+
using (var orchestrator = new EnrollmentOrchestrator(
- sessionId: sessionId,
- tenantId: tenantId,
+ sessionId: agentConfig.SessionId,
+ tenantId: agentConfig.TenantId,
stateDirectory: stateSubdir,
transportDirectory: transportDir,
clock: SystemClock.Instance,
@@ -194,13 +344,13 @@ namespace AutopilotMonitor.Agent.V2
uploader: uploader,
classifiers: classifiers,
componentFactory: componentFactory,
- whiteGloveSealingPatternIds: whiteGloveSealingPatternIds))
+ whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
+ agentMaxLifetime: agentMaxLifetime))
{
using (var shutdown = new ManualResetEventSlim(false))
{
ConsoleCancelEventHandler cancelHandler = (s, e) =>
{
- // Prevent the process from terminating immediately — let the orchestrator drain first.
e.Cancel = true;
logger.Info("Ctrl+C received — initiating graceful shutdown.");
shutdown.Set();
@@ -210,18 +360,27 @@ namespace AutopilotMonitor.Agent.V2
logger.Info("ProcessExit — initiating graceful shutdown.");
shutdown.Set();
};
+ EventHandler terminatedHandler = (s, e) =>
+ {
+ logger.Info($"Orchestrator signalled termination (reason={e.Reason}, outcome={e.Outcome}).");
+ shutdown.Set();
+ };
Console.CancelKeyPress += cancelHandler;
AppDomain.CurrentDomain.ProcessExit += processExitHandler;
+ orchestrator.Terminated += terminatedHandler;
try
{
orchestrator.Start();
- logger.Info($"V2 agent runtime ready (session={sessionId}, tenant={tenantId}).");
+
+ // Emit the agent_version_check event now that the EventEmitter is alive.
+ // VersionCheckEventBuilder.TryBuild is a no-op when no markers are present.
+ EmitVersionCheckEventIfAny(orchestrator, agentConfig, logger);
+
+ logger.Info($"V2 agent runtime ready (session={agentConfig.SessionId}, tenant={agentConfig.TenantId}).");
if (consoleMode)
- {
Console.Out.WriteLine("AutopilotMonitor.Agent.V2 running. Press Ctrl+C to stop.");
- }
shutdown.Wait();
}
@@ -229,6 +388,7 @@ namespace AutopilotMonitor.Agent.V2
{
Console.CancelKeyPress -= cancelHandler;
AppDomain.CurrentDomain.ProcessExit -= processExitHandler;
+ orchestrator.Terminated -= terminatedHandler;
try { orchestrator.Stop(); }
catch (Exception ex) { logger.Error("Orchestrator stop failed.", ex); }
@@ -243,26 +403,73 @@ namespace AutopilotMonitor.Agent.V2
return 0;
}
+ private static void EmitVersionCheckEventIfAny(
+ EnrollmentOrchestrator orchestrator,
+ AgentConfiguration agentConfig,
+ AgentLogger logger)
+ {
+ try
+ {
+ var buildResult = VersionCheckEventBuilder.TryBuild(
+ sessionId: agentConfig.SessionId,
+ tenantId: agentConfig.TenantId,
+ agentStartTimeUtc: DateTime.UtcNow);
+
+ if (!string.IsNullOrEmpty(buildResult?.ParseError))
+ logger.Warning($"VersionCheckEventBuilder parse error: {buildResult.ParseError}");
+
+ if (buildResult?.Event != null)
+ {
+ orchestrator.EventEmitter.Emit(buildResult.Event);
+ logger.Info($"agent_version_check emitted (outcome={buildResult.Outcome}).");
+ }
+ else if (buildResult?.Deduped == true)
+ {
+ logger.Debug($"agent_version_check deduped (outcome={buildResult.Outcome}).");
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"VersionCheckEventBuilder emission failed: {ex.Message}");
+ }
+ }
+
// ---------------------------------------------------------------- Configuration
- private static AgentConfiguration BuildAgentConfiguration(string[] args, string tenantId, string sessionId)
+ private static AgentConfiguration BuildAgentConfiguration(
+ string[] args,
+ string tenantId,
+ string sessionId,
+ BootstrapConfigFile bootstrapConfig,
+ AwaitEnrollmentConfigFile awaitConfig)
{
var apiBaseUrl = GetArgValue(args, "--api-url", "--backend-api") ?? Constants.ApiBaseUrl;
var imeLogPathOverride = GetArgValue(args, "--ime-log-path");
var imeMatchLogPath = GetArgValue(args, "--ime-match-log");
- var bootstrapToken = GetArgValue(args, "--bootstrap-token");
- var awaitEnrollment = args.Contains("--await-enrollment");
+
+ var bootstrapToken = GetArgValue(args, "--bootstrap-token")
+ ?? bootstrapConfig?.BootstrapToken;
+
+ var awaitEnrollment = args.Contains("--await-enrollment") || awaitConfig != null;
var rebootOnComplete = args.Contains("--reboot-on-complete");
var disableGeoLocation = args.Contains("--disable-geolocation");
var keepLogFile = args.Contains("--keep-logfile");
+ var noCleanup = args.Contains("--no-cleanup");
var awaitTimeoutRaw = GetArgValue(args, "--await-enrollment-timeout");
var awaitTimeoutMinutes = 480;
if (!string.IsNullOrEmpty(awaitTimeoutRaw) && int.TryParse(awaitTimeoutRaw, out var parsedTimeout))
awaitTimeoutMinutes = parsedTimeout;
+ else if (awaitConfig != null)
+ awaitTimeoutMinutes = awaitConfig.TimeoutMinutes;
var useBootstrapTokenAuth = !string.IsNullOrEmpty(bootstrapToken);
+ var cliLogLevel = GetArgValue(args, "--log-level");
+ var logLevel = AgentLogLevel.Info;
+ if (!string.IsNullOrEmpty(cliLogLevel) && Enum.TryParse(cliLogLevel, ignoreCase: true, out var parsedLevel))
+ logLevel = parsedLevel;
+
return new AgentConfiguration
{
ApiBaseUrl = apiBaseUrl,
@@ -272,7 +479,7 @@ namespace AutopilotMonitor.Agent.V2
LogDirectory = Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
UploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds,
MaxBatchSize = Constants.MaxBatchSize,
- LogLevel = AgentLogLevel.Info,
+ LogLevel = logLevel,
UseClientCertAuth = !useBootstrapTokenAuth,
BootstrapToken = bootstrapToken,
UseBootstrapTokenAuth = useBootstrapTokenAuth,
@@ -283,11 +490,39 @@ namespace AutopilotMonitor.Agent.V2
ImeLogPathOverride = imeLogPathOverride,
ImeMatchLogPath = imeMatchLogPath,
KeepLogFile = keepLogFile,
+ SelfDestructOnComplete = !noCleanup,
CommandLineArgs = FormatArgsForLog(args),
};
}
- private static string GetArgValue(string[] args, params string[] names)
+ private static bool LoadCachedSelfUpdateContext()
+ {
+ try
+ {
+ var path = Environment.ExpandEnvironmentVariables(CachedRemoteConfigPath);
+ if (!File.Exists(path)) return false;
+
+ var json = File.ReadAllText(path);
+ var cached = Newtonsoft.Json.JsonConvert.DeserializeObject(json);
+ if (cached == null) return false;
+
+ if (!string.IsNullOrEmpty(cached.LatestAgentSha256))
+ {
+ SelfUpdater.BackendExpectedSha256 = cached.LatestAgentSha256;
+ SelfUpdater.Log(
+ $"Self-update: loaded backend integrity hash from cached config (sha256={cached.LatestAgentSha256.Substring(0, Math.Min(12, cached.LatestAgentSha256.Length))}...)");
+ }
+
+ return cached.AllowAgentDowngrade;
+ }
+ catch (Exception ex)
+ {
+ SelfUpdater.Log($"Self-update: cached config read failed: {ex.Message}");
+ return false;
+ }
+ }
+
+ internal static string GetArgValue(string[] args, params string[] names)
{
if (args == null || args.Length < 2 || names == null) return null;
for (int i = 0; i < args.Length - 1; i++)
@@ -325,25 +560,38 @@ namespace AutopilotMonitor.Agent.V2
private static void PrintUsage()
{
- Console.Out.WriteLine("AutopilotMonitor.Agent.V2 — Autopilot-Monitor V2 agent.");
+ var v = GetAgentVersion();
+ Console.Out.WriteLine($"Autopilot Monitor Agent V2 v{v}");
Console.Out.WriteLine();
- Console.Out.WriteLine("Usage:");
- Console.Out.WriteLine(" AutopilotMonitor.Agent.V2.exe [options]");
+ Console.Out.WriteLine("Usage: AutopilotMonitor.Agent.V2.exe [options]");
Console.Out.WriteLine();
- Console.Out.WriteLine("Options:");
- Console.Out.WriteLine(" --help, -h, -? Show this message and exit");
- Console.Out.WriteLine(" --version Print version and exit");
- Console.Out.WriteLine(" --console Mirror log output to stdout");
- Console.Out.WriteLine(" --new-session Discard any persisted SessionId and create a fresh one");
- Console.Out.WriteLine(" --api-url Override backend base URL (alias: --backend-api)");
- Console.Out.WriteLine(" --bootstrap-token Use pre-MDM bootstrap token auth instead of client cert");
- Console.Out.WriteLine(" --await-enrollment Wait for MDM certificate before starting");
- Console.Out.WriteLine(" --await-enrollment-timeout Await-enrollment timeout (default: 480min)");
- Console.Out.WriteLine(" --ime-log-path Override IME log folder (testing/replay)");
- Console.Out.WriteLine(" --ime-match-log Write IME pattern matches to this file");
- Console.Out.WriteLine(" --reboot-on-complete Reboot device after successful enrollment");
- Console.Out.WriteLine(" --disable-geolocation Disable external IP geo-location lookup");
- Console.Out.WriteLine(" --keep-logfile Preserve the agent log file on self-destruct");
+ Console.Out.WriteLine("Modes:");
+ Console.Out.WriteLine(" --install Deploy payload, create Scheduled Task, and start it");
+ Console.Out.WriteLine(" --tenant-id Tenant ID for bootstrap-config (used with --install)");
+ Console.Out.WriteLine(" (default) Run enrollment monitoring");
+ Console.Out.WriteLine();
+ Console.Out.WriteLine("General options:");
+ Console.Out.WriteLine(" --help, -h, -? Show this help message");
+ Console.Out.WriteLine(" --version Print version and exit");
+ Console.Out.WriteLine(" --console Enable console output (mirrors log to stdout)");
+ Console.Out.WriteLine(" --log-level Override log level (Info, Debug, Verbose, Trace)");
+ Console.Out.WriteLine(" --new-session Force a new session ID (delete persisted session)");
+ Console.Out.WriteLine(" --keep-logfile Preserve log directory after self-destruct cleanup");
+ Console.Out.WriteLine(" --no-cleanup Disable self-destruct on enrollment completion");
+ Console.Out.WriteLine(" --reboot-on-complete Reboot the device after enrollment completes");
+ Console.Out.WriteLine(" --disable-geolocation Skip geo-location detection");
+ Console.Out.WriteLine();
+ Console.Out.WriteLine("Authentication:");
+ Console.Out.WriteLine(" --bootstrap-token Use bootstrap token auth (pre-MDM OOBE phase)");
+ Console.Out.WriteLine();
+ Console.Out.WriteLine("Await-enrollment mode:");
+ Console.Out.WriteLine(" --await-enrollment Wait for MDM certificate before starting monitoring");
+ Console.Out.WriteLine(" --await-enrollment-timeout Timeout in minutes (default: 480)");
+ Console.Out.WriteLine();
+ Console.Out.WriteLine("Overrides:");
+ Console.Out.WriteLine(" --api-url Override backend API base URL (alias: --backend-api)");
+ Console.Out.WriteLine(" --ime-log-path Override IME logs directory");
+ Console.Out.WriteLine(" --ime-match-log Write matched IME log lines to file (debug)");
}
private static void PrintVersion()
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index a45fd52c..f64f5d8d 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-24
+27
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 373aef1e..00146f25 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1064
+1066