diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs index 3a0d2daa..c2a1b293 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs @@ -75,6 +75,99 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program $"Expected hard_kill or reboot_kill, got {summary.ExitType}."); } + // ================================================================= CheckEnrollmentCompleteMarker + + [Fact] + public void CheckEnrollmentCompleteMarker_returns_false_on_first_run_after_install_when_session_exists() + { + // Regression guard for the first-run-after-install bug: Program.cs must create the + // SessionId BEFORE invoking this guard, so a fresh install (Deployed registry marker + // written by --install, no prior session) does not get misdiagnosed as a ghost + // restart and trigger self-destruct before FetchConfigAsync. + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + new SessionIdPersistence(tmp.Path).GetOrCreate(logger); + var stateDir = Path.Combine(tmp.Path, "State"); + var factoryCalls = 0; + + var shouldExit = AutopilotMonitor.Agent.V2.Program.CheckEnrollmentCompleteMarker( + dataDirectory: tmp.Path, + stateDirectory: stateDir, + selfDestructOnComplete: true, // worst case — cleanup armed + cleanupServiceFactory: () => + { + factoryCalls++; + throw new InvalidOperationException( + "test-harness: cleanup factory must not be invoked in this scenario"); + }, + logger: logger, + consoleMode: false); + + Assert.False(shouldExit); + Assert.Equal(0, factoryCalls); + } + + [Fact] + public void CheckEnrollmentCompleteMarker_returns_true_without_firing_cleanup_when_marker_present_and_no_selfdestruct() + { + // Remote config said "don't self-destruct": agent must exit cleanly but leave the + // ProgramData directory intact for post-mortem inspection. + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + new SessionIdPersistence(tmp.Path).GetOrCreate(logger); + var stateDir = Path.Combine(tmp.Path, "State"); + Directory.CreateDirectory(stateDir); + File.WriteAllText(Path.Combine(stateDir, "enrollment-complete.marker"), "previous completion"); + var factoryCalls = 0; + + var shouldExit = AutopilotMonitor.Agent.V2.Program.CheckEnrollmentCompleteMarker( + dataDirectory: tmp.Path, + stateDirectory: stateDir, + selfDestructOnComplete: false, + cleanupServiceFactory: () => + { + factoryCalls++; + throw new InvalidOperationException( + "test-harness: cleanup factory must not be invoked in this scenario"); + }, + logger: logger, + consoleMode: false); + + Assert.True(shouldExit); + Assert.Equal(0, factoryCalls); + } + + [Fact] + public void CheckEnrollmentCompleteMarker_fires_cleanup_retry_when_marker_present_and_selfdestruct_armed() + { + // Scheduled-task-survived-self-destruct retry path. CleanupService actually spawns a + // PowerShell cleanup script — we throw from the factory so the real side-effect does + // not fire; TryRetryCleanup swallows the exception and logs a warning. + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + new SessionIdPersistence(tmp.Path).GetOrCreate(logger); + var stateDir = Path.Combine(tmp.Path, "State"); + Directory.CreateDirectory(stateDir); + File.WriteAllText(Path.Combine(stateDir, "enrollment-complete.marker"), "previous completion"); + var factoryCalls = 0; + + var shouldExit = AutopilotMonitor.Agent.V2.Program.CheckEnrollmentCompleteMarker( + dataDirectory: tmp.Path, + stateDirectory: stateDir, + selfDestructOnComplete: true, + cleanupServiceFactory: () => + { + factoryCalls++; + throw new InvalidOperationException( + "test-harness: must not spawn real PowerShell cleanup in unit tests"); + }, + logger: logger, + consoleMode: false); + + Assert.True(shouldExit); + Assert.Equal(1, factoryCalls); + } + // ================================================================= Emergency break [Fact] diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index 04937b7c..c4989313 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -36,11 +36,11 @@ namespace AutopilotMonitor.Agent.V2 /// 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 + /// Get/create SessionId via — before the guards, matching Legacy boot order. The ghost-restart guard keys on session.id absence to detect "self-destruct ran but Scheduled Task survived"; creating the session after the guard would misdiagnose every first-run-after-install as a ghost restart and trigger self-destruct before the remote-config fetch. + /// — ghost-restart detection + cleanup retry + /// — absolute session-age watchdog /// (Optional) Wait for MDM certificate in --await-enrollment mode /// Build + → fetch config /// — H-2 mitigation post-cert @@ -197,13 +197,21 @@ namespace AutopilotMonitor.Agent.V2 // --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. + var agentConfig = BuildAgentConfiguration(args, tenantId, sessionId: null, bootstrapConfig, awaitConfig); + + // Create/recover SessionId BEFORE the startup guards. Legacy-parity boot order — + // the ghost-restart guard below keys on session.id absence to detect + // "self-destruct ran but Scheduled Task survived". Creating the session after the + // guard would misdiagnose every first-run-after-install (Deployed registry marker + // set by --install, no session.id yet) as a ghost restart and trigger + // ExecuteSelfDestruct before the remote-config fetch, deleting the ProgramData + // directory on every fresh deployment. if (args.Contains("--new-session")) { new SessionIdPersistence(dataDirectory).Delete(logger); logger.Info("--new-session: cleared persisted SessionId."); } - - var agentConfig = BuildAgentConfiguration(args, tenantId, sessionId: null, bootstrapConfig, awaitConfig); + agentConfig.SessionId = new SessionIdPersistence(dataDirectory).GetOrCreate(logger); // Build a cleanup-service factory used by guards: instantiated lazily and with the // current agentConfig so command-line overrides (e.g. --no-cleanup) take effect. @@ -226,10 +234,6 @@ namespace AutopilotMonitor.Agent.V2 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) { logger.Info($"--await-enrollment: polling for MDM certificate (timeout: {agentConfig.AwaitEnrollmentTimeoutMinutes}min).");