Merge pull request #44 from okieselbach/v2-hotfix-first-run-ghost-detection

fix(agent): V2 hotfix — first-run ghost-detect triggers self-destruct on fresh install
This commit is contained in:
Oliver Kieselbach
2026-04-21 17:12:19 +02:00
committed by GitHub
2 changed files with 106 additions and 9 deletions
@@ -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]
+13 -9
View File
@@ -36,11 +36,11 @@ namespace AutopilotMonitor.Agent.V2
/// <item>Load cached <c>remote-config.json</c> → <see cref="SelfUpdater.BackendExpectedSha256"/> + <c>AllowAgentDowngrade</c></item>
/// <item><see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> — on success restarts the process; on failure continues with current binary</item>
/// <item><see cref="DetectPreviousExit"/> — reads markers + event log to classify last shutdown</item>
/// <item><see cref="CheckEnrollmentCompleteMarker"/> — ghost-restart detection + cleanup retry</item>
/// <item><see cref="CheckSessionAgeEmergencyBreak"/> — absolute session-age watchdog</item>
/// <item>Resolve TenantId (registry → bootstrap-config.json fallback)</item>
/// <item>Build <see cref="AgentConfiguration"/> (CLI args + persisted bootstrap / await-enrollment config)</item>
/// <item>Get/create SessionId via <see cref="SessionIdPersistence"/></item>
/// <item>Get/create SessionId via <see cref="SessionIdPersistence"/> — <b>before</b> the guards, matching Legacy boot order. The ghost-restart guard keys on <c>session.id</c> 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.</item>
/// <item><see cref="CheckEnrollmentCompleteMarker"/> — ghost-restart detection + cleanup retry</item>
/// <item><see cref="CheckSessionAgeEmergencyBreak"/> — absolute session-age watchdog</item>
/// <item>(Optional) Wait for MDM certificate in <c>--await-enrollment</c> mode</item>
/// <item>Build <see cref="BackendApiClient"/> + <see cref="RemoteConfigService"/> → fetch config</item>
/// <item><see cref="BootstrapConfigCleanup.TryDeleteIfCertReadyAsync"/> — H-2 mitigation post-cert</item>
@@ -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).");