feat(agent-v2): M4.6.α peripheral parity — lifecycle, install, self-update, watchdog

Closes the first batch of peripheral-parity gaps between Legacy and V2 so the
V2 agent is actually deployable/runnable in production. M4 kernel is unchanged
— only wiring and startup helpers.

Deployment + install
- Program.InstallMode.cs: --install mode ported 1:1 (payload copy, schtasks
  Create/Run, bootstrap-config.json + await-enrollment.json persistence,
  HKLM\SOFTWARE\AutopilotMonitor\Deployed marker)
- Configuration/BootstrapConfigFile.cs + AwaitEnrollmentConfigFile.cs
- Scheduled Task target is AutopilotMonitor.Agent.V2.exe (same task name
  as Legacy — V2 replaces Legacy on the device)

Startup guards (Program.Guards.cs)
- Multi-instance guard (IsAnotherAgentInstanceRunning)
- clean-exit.marker writer registered in AppDomain.ProcessExit
- DetectPreviousExit: reads marker + crash_*.log + session files + event log
  → classifies {first_run, clean, exception_crash, hard_kill, reboot_kill}
- CheckEnrollmentCompleteMarker: ghost-restart detection via HKLM Deployed
  key + enrollment-complete.marker → cleanup-retry + exit
- CheckSessionAgeEmergencyBreak: AbsoluteMaxSessionHours watchdog across
  restarts (respects whiteglove.complete pause)
- WriteCrashLog: last-resort crash_YYYYMMDD_HHMMSS.log
- TryRead/Delete bootstrap-config.json + await-enrollment.json helpers

Self-update (SelfUpdater wired for the first time in V2 startup)
- CleanupPreviousUpdate + CheckAndApplyUpdateAsync called BEFORE logger init
- Cached remote-config.json read → BackendExpectedSha256 + AllowAgentDowngrade
- VersionCheckEventBuilder wired: emits agent_version_check event via
  orchestrator.EventEmitter right after Start (when startup markers exist)

Agent max-lifetime watchdog (EnrollmentOrchestrator)
- New ctor param agentMaxLifetime: TimeSpan?
- New typed API: event EventHandler<EnrollmentTerminatedEventArgs> Terminated
  with EnrollmentTerminationReason (MaxLifetimeExceeded | DecisionTerminalStage)
  + EnrollmentTerminationOutcome (Succeeded | Failed | TimedOut)
- M4.6.α fires MaxLifetimeExceeded only; DecisionTerminalStage is the M4.6.β
  hook for CleanupService self-destruct + SummaryDialog launch
- Program.cs wires the event to the shutdown ManualResetEventSlim

Bootstrap-config cleanup (H-2 mitigation)
- BootstrapConfigCleanup.TryDeleteIfCertReadyAsync now actually called after
  RemoteConfigService.FetchConfigAsync (cert probe confirms mTLS works)

SessionIdPersistence extensions
- New session.created companion file (UTC roundtrip) for the emergency break
- whiteglove.complete resume marker awareness
- SessionExists / LoadSessionCreatedAt / SaveSessionCreatedAt / IsWhiteGloveResume
- Delete now clears session.id + session.created + whiteglove.complete

Tests (+26 in V2.Core.Tests → 295 → 321)
- SessionIdPersistenceTests: +8 tests covering the new methods
- EnrollmentOrchestratorMaxLifetimeTests: watchdog fires once, respects null,
  rejects non-positive, is cancelled by Stop (under SerialThreading collection)
- ProgramGuardsTests: 13 tests covering DetectPreviousExit state machine,
  CheckSessionAgeEmergencyBreak (pass/trip/whiteglove-resume/recover-missing-
  session.created paths), TryRead/Delete bootstrap + await-enrollment configs,
  WriteCrashLog format
- V2.Core.Tests gains ProjectReference to V2 exe + V2 exe exposes
  InternalsVisibleTo so the Program.Guards internal statics are testable
  without duplicating code into V2.Core

All 4 suites green: 73 DecisionCore + 321 V2.Core + 507 Agent.Core + 557 Functions
= 1458 tests green.

Still open for M4.6.β: CleanupService terminal-event wiring, ServerActionDispatcher,
DiagnosticsPackageService, SummaryDialog launch + FinalStatusBuilder,
UserSessionProcessLauncher / UserProfileResolver wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-21 00:14:48 +02:00
parent 9f45b5b7a6
commit 02bbb5490c
15 changed files with 1716 additions and 73 deletions
@@ -25,8 +25,10 @@
</ItemGroup>
<ItemGroup>
<!-- V2.Core + its own dependencies. Plan §2.16 isolation: NO reference to Agent.Core. -->
<!-- V2.Core + V2 exe (for Program.Guards startup helpers, M4.6.α) + dependencies.
Plan §2.16 isolation: NO reference to Agent.Core. -->
<ProjectReference Include="..\AutopilotMonitor.Agent.V2.Core\AutopilotMonitor.Agent.V2.Core.csproj" />
<ProjectReference Include="..\AutopilotMonitor.Agent.V2\AutopilotMonitor.Agent.V2.csproj" />
<ProjectReference Include="..\..\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj" />
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
</ItemGroup>
@@ -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
{
/// <summary>
/// M4.6.α — max-lifetime watchdog. When the configured lifetime elapses without a
/// decision-terminal stage, the orchestrator raises <see cref="EnrollmentOrchestrator.Terminated"/>
/// with reason <see cref="EnrollmentTerminationReason.MaxLifetimeExceeded"/> and outcome
/// <see cref="EnrollmentTerminationOutcome.TimedOut"/>.
/// </summary>
[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<IClassifier>(),
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<ArgumentOutOfRangeException>(() => Build(tmp, agentMaxLifetime: TimeSpan.Zero));
Assert.Throws<ArgumentOutOfRangeException>(() => 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);
}
}
}
@@ -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
{
/// <summary>
/// Tests for the M4.6.α startup guards in <c>Program.Guards.cs</c>. These helpers are
/// <c>internal static</c>; the V2 exe exposes its internals to this test assembly via
/// <c>[InternalsVisibleTo]</c>.
/// </summary>
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);
}
}
}
@@ -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")));
}
}
}
@@ -0,0 +1,16 @@
namespace AutopilotMonitor.Agent.V2.Core.Configuration
{
/// <summary>
/// Persisted await-enrollment configuration. Plan §4.x M4.6.α.
/// <para>
/// Saved to <c>%ProgramData%\AutopilotMonitor\await-enrollment.json</c> by <c>--install</c> 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.
/// </para>
/// </summary>
public sealed class AwaitEnrollmentConfigFile
{
public int TimeoutMinutes { get; set; } = 480;
}
}
@@ -0,0 +1,16 @@
namespace AutopilotMonitor.Agent.V2.Core.Configuration
{
/// <summary>
/// Persisted bootstrap configuration for OOBE-bootstrapped agents. Plan §4.x M4.6.α.
/// <para>
/// Saved to <c>%ProgramData%\AutopilotMonitor\bootstrap-config.json</c> by <c>--install</c> 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.
/// </para>
/// </summary>
public sealed class BootstrapConfigFile
{
public string BootstrapToken { get; set; }
public string TenantId { get; set; }
}
}
@@ -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;
}
/// <summary>
/// Terminal event — fires once when the orchestrator declares the session done. Plan §4.x M4.6.α.
/// <para>
/// M4.6.α fires only <see cref="EnrollmentTerminationReason.MaxLifetimeExceeded"/>; the
/// <see cref="EnrollmentTerminationReason.DecisionTerminalStage"/> path is wired in M4.6.β
/// together with <c>CleanupService</c> self-destruct + SummaryDialog launch.
/// </para>
/// <para>
/// Handlers may run on a ThreadPool thread (Timer callback). They must NOT call
/// <see cref="Stop"/> directly (re-entrant) — raise a shutdown <see cref="ManualResetEventSlim"/>
/// from the handler and let the main thread call <see cref="Stop"/>.
/// </para>
/// </summary>
public event EventHandler<EnrollmentTerminatedEventArgs>? Terminated;
// ---------------------------------------------------------------- Observability
/// <summary>Aktueller <see cref="DecisionState"/> — nur nach <see cref="Start"/> verfügbar.</summary>
@@ -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); }
}
/// <summary>
/// Stop the pipeline. Idempotent; calls <see cref="Stop"/> from <see cref="Dispose"/> as well.
/// </summary>
@@ -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)
{
@@ -0,0 +1,77 @@
#nullable enable
using System;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Reasons for which <see cref="EnrollmentOrchestrator.Terminated"/> fires. Plan §4.x M4.6.α.
/// </summary>
public enum EnrollmentTerminationReason
{
/// <summary>DecisionEngine reached a terminal stage via real signals (success or failure path).</summary>
DecisionTerminalStage,
/// <summary>
/// <c>AgentMaxLifetimeMinutes</c> safety watchdog fired — orchestrator has been running
/// longer than the configured cap without reaching a terminal stage.
/// </summary>
MaxLifetimeExceeded,
}
/// <summary>
/// Outcome descriptor emitted alongside <see cref="EnrollmentOrchestrator.Terminated"/>.
/// <para>
/// M4.6.β will consume this to drive <c>CleanupService.ExecuteSelfDestruct</c>,
/// <c>SummaryDialog</c> launch and <c>DiagnosticsPackageService</c> upload — the orchestrator
/// stays kernel-pure and only declares "we are done, here is the outcome".
/// </para>
/// </summary>
public enum EnrollmentTerminationOutcome
{
/// <summary>
/// Reached a success stage (<c>Completed</c> / <c>WhiteGloveSealed</c> for Part 1 exit).
/// </summary>
Succeeded,
/// <summary>Reached an explicit failure stage.</summary>
Failed,
/// <summary>
/// Not yet terminal (used when the watchdog fires mid-flight — no classifier verdict,
/// stage is still an in-progress state).
/// </summary>
TimedOut,
}
/// <summary>
/// Payload for <see cref="EnrollmentOrchestrator.Terminated"/>. Plan §4.x M4.6.α.
/// <para>
/// 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.
/// </para>
/// </summary>
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;
}
}
}
@@ -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");
}
/// <summary>
/// Returns the persisted SessionId or creates, writes and returns a fresh one.
/// Thread-safe — concurrent callers see the same SessionId.
/// <para>
/// Also initialises <c>session.created</c> on first creation (and on recovery when it is
/// missing) so the emergency-break watchdog can compare the session's wall-clock age.
/// </para>
/// </summary>
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;
}
}
/// <summary>True when a persisted SessionId is present on disk.</summary>
public bool SessionExists()
{
lock (_lockObject) return File.Exists(_sessionFilePath);
}
/// <summary>
/// Returns the persisted session creation timestamp (UTC) or <c>null</c> when unavailable /
/// unparseable. Used by the M4.6.α emergency-break watchdog.
/// </summary>
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;
}
}
/// <summary>
/// Persists the session creation timestamp (UTC). Used by the watchdog to establish a
/// known-good baseline if the file is missing on recovery (see <see cref="GetOrCreate"/>).
/// </summary>
public void SaveSessionCreatedAt(DateTime createdAtUtc)
{
lock (_lockObject) SaveSessionCreatedAtInternal(createdAtUtc);
}
/// <summary>
/// <c>true</c> when a <c>whiteglove.complete</c> 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.
/// </summary>
public bool IsWhiteGloveResume()
{
lock (_lockObject) return File.Exists(_whiteGloveMarkerPath);
}
/// <summary>Deletes the persisted SessionId. Next <see cref="GetOrCreate"/> starts a fresh session.</summary>
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";
@@ -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")]
@@ -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
{
/// <summary>
/// Startup guards ported from Legacy <c>Program.cs</c>. Plan §4.x M4.6.α.
/// <list type="bullet">
/// <item><b>Multi-instance guard</b> — prevents a second agent process from running alongside
/// one that was started by the Scheduled Task.</item>
/// <item><b>clean-exit.marker</b> — written in <see cref="AppDomain.ProcessExit"/> so the next
/// start can distinguish clean shutdown from hard kill / reboot kill / exception crash.</item>
/// <item><b>Previous-exit classification</b> — reads the marker, crash logs and the system
/// event log (boot time 6009) and determines the previous exit type.</item>
/// <item><b>Enrollment-complete marker check</b> — ghost-restart detection: if a previous
/// session completed successfully but the Scheduled Task survived self-destruct, retry
/// cleanup and exit.</item>
/// <item><b>Session-age emergency break</b> — absolute cap (<c>AbsoluteMaxSessionHours</c>)
/// across restarts prevents zombie agents from persisting forever.</item>
/// <item><b>Crash log writer</b> — writes a last-resort crash_*.log next to the agent log
/// directory when an uncaught exception escapes <c>Main</c>.</item>
/// </list>
/// </summary>
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
/// <summary>
/// <c>true</c> 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.
/// </summary>
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
/// <summary>
/// Registers a <see cref="AppDomain.ProcessExit"/> handler that writes <c>clean-exit.marker</c>
/// as a last action so the next start can distinguish clean shutdown from hard kill.
/// <para>Safe to call multiple times — duplicate handlers are filtered by delegate equality.</para>
/// </summary>
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
/// <summary>Lightweight bag returned by <see cref="DetectPreviousExit"/>.</summary>
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"
}
/// <summary>
/// Reads <c>clean-exit.marker</c> + <c>crash_*.log</c> + 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.
/// </summary>
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<string>();
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;
}
/// <summary>
/// Queries the Windows System event log for the latest OS boot event (ID 6009). Returns
/// <c>null</c> when the query fails (e.g. on reduced-privilege runs in dev environments).
/// </summary>
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
/// <summary>
/// <c>true</c> when an <c>enrollment-complete.marker</c> or the <c>Deployed</c> registry
/// marker indicates that this device already completed enrollment. Legacy parity —
/// triggers a self-destruct retry and the agent exits cleanly.
/// </summary>
internal static bool CheckEnrollmentCompleteMarker(
string dataDirectory,
string stateDirectory,
bool selfDestructOnComplete,
Func<CleanupService> 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;
}
/// <summary>
/// Emergency break: exits the agent if the current session has been alive longer than
/// <c>AbsoluteMaxSessionHours</c>. Respects WhiteGlove-paused sessions (the age clock
/// restarts on Part-2 resume). Legacy parity.
/// </summary>
internal static bool CheckSessionAgeEmergencyBreak(
string dataDirectory,
string stateDirectory,
int absoluteMaxSessionHours,
bool selfDestructOnComplete,
Func<CleanupService> 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<CleanupService> 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
/// <summary>
/// Writes a last-resort crash_*.log next to the agent log directory when an uncaught
/// exception escapes <see cref="Main"/>. The next start reads this file and emits an
/// <c>exception_crash</c> previous-exit classification.
/// </summary>
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
/// <summary>
/// Reads a persisted <c>bootstrap-config.json</c> (if present) that was written by
/// <c>--install</c>. Returns <c>null</c> on miss or corrupt file.
/// </summary>
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<BootstrapConfigFile>(json);
}
catch (Exception ex)
{
logger?.Debug($"TryReadBootstrapConfig: {ex.Message}");
return null;
}
}
/// <summary>
/// Reads a persisted <c>await-enrollment.json</c> (if present). Returns <c>null</c> on
/// miss or corrupt file.
/// </summary>
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<AwaitEnrollmentConfigFile>(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}");
}
}
}
}
@@ -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
{
/// <summary>
/// <c>--install</c> 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.α.
/// <para>
/// Ported 1:1 from Legacy <c>Program.InstallMode.cs</c> — 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.
/// </para>
/// </summary>
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}");
}
}
}
}
+309 -61
View File
@@ -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
{
/// <summary>
/// V2-Agent entry point. Plan §4.x M4.5.b.
/// V2-Agent entry point. Plan §4.x M4.5.b + M4.6.α.
/// <para>
/// Start sequence:
/// Boot sequence (same order as Legacy for feature parity):
/// </para>
/// <list type="number">
/// <item>Parse CLI args (<c>--help</c>, <c>--version</c>, <c>--console</c>, <c>--new-session</c>,
/// <c>--api-url</c>, <c>--bootstrap-token</c>, <c>--await-enrollment</c>, <c>--ime-log-path</c>,
/// <c>--ime-match-log</c>)</item>
/// <item>Initialise <see cref="AgentLogger"/> on <c>%ProgramData%\AutopilotMonitor\Logs</c></item>
/// <item>Resolve TenantId via <see cref="TenantIdResolver"/></item>
/// <item>Load/create SessionId via <see cref="SessionIdPersistence"/></item>
/// <item>(Optional) Wait for MDM certificate in await-enrollment mode</item>
/// <item>Build mTLS-<see cref="HttpClient"/> via <see cref="MtlsHttpClientFactory"/></item>
/// <item>Fetch remote config via <see cref="BackendApiClient"/> + <see cref="RemoteConfigService"/></item>
/// <item>Wire <see cref="BackendTelemetryUploader"/> for telemetry batch uploads</item>
/// <item>Construct <see cref="DefaultComponentFactory"/> + <see cref="EnrollmentOrchestrator"/></item>
/// <item><c>orchestrator.Start()</c> and wait for <c>Ctrl+C</c> / <see cref="AppDomain.ProcessExit"/></item>
/// <item><c>orchestrator.Stop()</c>, return 0</item>
/// <item><c>--help</c> / <c>--version</c> short-circuit</item>
/// <item><c>--install</c> forks to <see cref="RunInstallMode"/> and exits</item>
/// <item>Multi-instance guard — single-agent invariant</item>
/// <item>Register <c>ProcessExit</c> → writes <c>clean-exit.marker</c></item>
/// <item>Ensure agent directories exist</item>
/// <item><see cref="SelfUpdater.LogInit"/> + <see cref="SelfUpdater.CleanupPreviousUpdate"/></item>
/// <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>(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>
/// <item>Build mTLS <see cref="HttpClient"/> → <see cref="BackendTelemetryUploader"/></item>
/// <item>Build <see cref="DefaultComponentFactory"/> + <see cref="EnrollmentOrchestrator"/></item>
/// <item><c>orchestrator.Start()</c> → emit <see cref="VersionCheckEventBuilder"/>-derived event</item>
/// <item>Wait for Ctrl+C / ProcessExit / EnrollmentTerminated</item>
/// <item><c>orchestrator.Stop()</c>, exit 0</item>
/// </list>
/// </summary>
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<CleanupService> 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<string>)remoteConfig.WhiteGloveSealingPatternIds
?? Array.Empty<string>();
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<EnrollmentTerminatedEventArgs> 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<AgentLogLevel>(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<AgentConfigResponse>(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 <url> Override backend base URL (alias: --backend-api)");
Console.Out.WriteLine(" --bootstrap-token <tok> 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 <min> Await-enrollment timeout (default: 480min)");
Console.Out.WriteLine(" --ime-log-path <dir> Override IME log folder (testing/replay)");
Console.Out.WriteLine(" --ime-match-log <path> 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 <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 <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 <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 <MIN> Timeout in minutes (default: 480)");
Console.Out.WriteLine();
Console.Out.WriteLine("Overrides:");
Console.Out.WriteLine(" --api-url <URL> Override backend API base URL (alias: --backend-api)");
Console.Out.WriteLine(" --ime-log-path <PATH> Override IME logs directory");
Console.Out.WriteLine(" --ime-match-log <PATH> Write matched IME log lines to file (debug)");
}
private static void PrintVersion()
@@ -1 +1 @@
24
27
@@ -1 +1 @@
1064
1066