mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
+3
-1
@@ -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>
|
||||
|
||||
+120
@@ -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)
|
||||
{
|
||||
|
||||
+77
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user