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