feat(agent-v2): M4.5.b runtime entry + DefaultComponentFactory

Wires the V2 agent into a runnable state:

- TenantIdResolver: HKLM\SOFTWARE\Microsoft\Enrollments → AADTenantID (EnrollmentType=6)
- SessionIdPersistence: atomic session.id via File.Replace
- IComponentFactory signature: +ISignalIngressSink +IClock
  (bridges the sink/clock access into factory-built adapters)
- DefaultComponentFactory: 5 kernel hosts (EspAndHelloTracker coordinator,
  DesktopArrivalDetector, AadJoinWatcher, ImeLogTracker+ImeProcessWatcher,
  StallProbeCollector) + 2 peripheral hosts (Performance, AgentSelfMetrics)
  conditional on CollectorConfiguration toggles
- Program.cs: full runtime entry (args → Logger → TenantId/SessionId →
  AwaitEnrollment → BackendApiClient+RemoteConfigService → MtlsHttpClientFactory
  → BackendTelemetryUploader → DefaultComponentFactory → EnrollmentOrchestrator
  → Ctrl+C wait → Stop → exit 0)

Production host choice: EspAndHelloTracker as single coordinator entry
(instead of 4 sub-tracker hosts in parallel) — avoids adapter signal
duplication documented in M4.3 tech-debt. StallProbeHost drives its own
60s idle-tick timer (Legacy MonitoringService.IdleCheckCallback is gone).

Tests: +8 SessionIdPersistenceTests (atomic-write, round-trip, corrupt-
recovery, regeneration, multi-instance resume). V2.Core 287→295, total
1424→1432 green.

Known peripheral gaps (tracked for follow-up M4.6): install mode,
SelfUpdater, ServerActionDispatcher, CleanupService, Geo/NTP/Timezone
wiring, Emergency/DistressReporter wiring, AnalyzerManager, SummaryDialog,
DiagnosticsPackageService, gather-rules/ime-matching modes, watchdog
timers, multi-instance guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-20 23:48:24 +02:00
parent 1e77305857
commit 0ee68c6192
11 changed files with 1129 additions and 24 deletions
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
using Xunit;
@@ -56,15 +57,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
public IReadOnlyCollection<string>? CapturedWhiteGloveSealingPatternIds { get; private set; }
public ISignalIngressSink? CapturedIngress { get; private set; }
public IClock? CapturedClock { get; private set; }
public IReadOnlyList<ICollectorHost> CreateCollectorHosts(
string sessionId,
string tenantId,
AgentLogger logger,
Action<EnrollmentEvent> onEnrollmentEvent,
IReadOnlyCollection<string> whiteGloveSealingPatternIds)
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
ISignalIngressSink ingress,
IClock clock)
{
CapturedSink = onEnrollmentEvent;
CapturedWhiteGloveSealingPatternIds = whiteGloveSealingPatternIds;
CapturedIngress = ingress;
CapturedClock = clock;
foreach (var name in HostNames)
{
Hosts.Add(new FakeCollectorHost(name, onEnrollmentEvent));
@@ -6,6 +6,7 @@ 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 AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
using Xunit;
@@ -24,7 +25,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
string tenantId,
AgentLogger logger,
Action<EnrollmentEvent> onEnrollmentEvent,
IReadOnlyCollection<string> whiteGloveSealingPatternIds)
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
ISignalIngressSink ingress,
IClock clock)
{
CapturedPatternIds = whiteGloveSealingPatternIds;
return Array.Empty<ICollectorHost>();
@@ -0,0 +1,110 @@
using System;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
public sealed class SessionIdPersistenceTests
{
[Fact]
public void GetOrCreate_without_existing_file_creates_a_new_guid()
{
using var tmp = new TempDirectory();
var sut = new SessionIdPersistence(tmp.Path);
var id = sut.GetOrCreate();
Assert.True(Guid.TryParse(id, out _));
Assert.True(File.Exists(Path.Combine(tmp.Path, "session.id")));
Assert.Equal(id, File.ReadAllText(Path.Combine(tmp.Path, "session.id")));
}
[Fact]
public void GetOrCreate_returns_same_id_on_repeated_calls()
{
using var tmp = new TempDirectory();
var sut = new SessionIdPersistence(tmp.Path);
var first = sut.GetOrCreate();
var second = sut.GetOrCreate();
var third = sut.GetOrCreate();
Assert.Equal(first, second);
Assert.Equal(first, third);
}
[Fact]
public void GetOrCreate_new_instance_resumes_persisted_session()
{
using var tmp = new TempDirectory();
var first = new SessionIdPersistence(tmp.Path).GetOrCreate();
var resumed = new SessionIdPersistence(tmp.Path).GetOrCreate();
Assert.Equal(first, resumed);
}
[Fact]
public void GetOrCreate_regenerates_when_file_content_is_corrupt()
{
using var tmp = new TempDirectory();
File.WriteAllText(Path.Combine(tmp.Path, "session.id"), "not-a-guid");
var id = new SessionIdPersistence(tmp.Path).GetOrCreate();
Assert.True(Guid.TryParse(id, out _));
Assert.NotEqual("not-a-guid", id);
}
[Fact]
public void Delete_clears_persisted_session_so_next_call_regenerates()
{
using var tmp = new TempDirectory();
var sut = new SessionIdPersistence(tmp.Path);
var first = sut.GetOrCreate();
sut.Delete();
Assert.False(File.Exists(Path.Combine(tmp.Path, "session.id")));
var second = sut.GetOrCreate();
Assert.NotEqual(first, second);
}
[Fact]
public void Ctor_creates_data_directory_when_missing()
{
using var tmp = new TempDirectory();
var nested = Path.Combine(tmp.Path, "nested", "state");
Assert.False(Directory.Exists(nested));
var sut = new SessionIdPersistence(nested);
var id = sut.GetOrCreate();
Assert.True(Directory.Exists(nested));
Assert.True(Guid.TryParse(id, out _));
}
[Fact]
public void Ctor_rejects_null_or_empty_data_directory()
{
Assert.Throws<ArgumentNullException>(() => new SessionIdPersistence(null!));
Assert.Throws<ArgumentNullException>(() => new SessionIdPersistence(""));
Assert.Throws<ArgumentNullException>(() => new SessionIdPersistence(" "));
}
[Fact]
public void GetOrCreate_with_logger_emits_resume_debug_on_second_call()
{
using var tmp = new TempDirectory();
var logDir = Path.Combine(tmp.Path, "logs");
var logger = new AgentLogger(logDir, AgentLogLevel.Debug);
var first = new SessionIdPersistence(tmp.Path).GetOrCreate(logger);
var second = new SessionIdPersistence(tmp.Path).GetOrCreate(logger);
Assert.Equal(first, second);
}
}
}
@@ -0,0 +1,474 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Production <see cref="IComponentFactory"/>. Plan §4.x M4.5.b.
/// <para>
/// Builds the real Collector-Hosts for the V2 agent runtime:
/// <list type="bullet">
/// <item><b>EspAndHelloHost</b> — <see cref="EspAndHelloTracker"/> coordinator
/// (internally aggregates HelloTracker + ShellCoreTracker + ProvisioningStatusTracker
/// + ModernDeploymentTracker) wired via <see cref="EspAndHelloTrackerAdapter"/>.
/// This is the single production entry for the ESP+Hello signal surface — avoids
/// double emission that would happen if the sub-tracker adapters were also wired
/// in parallel (§4.x M4.3 tech-debt note about adapter duplication).</item>
/// <item><b>DesktopArrivalHost</b> — <see cref="DesktopArrivalDetector"/> + <see cref="DesktopArrivalDetectorAdapter"/>.</item>
/// <item><b>AadJoinHost</b> — <see cref="AadJoinWatcher"/> + <see cref="AadJoinWatcherAdapter"/>.</item>
/// <item><b>ImeLogHost</b> — <see cref="ImeLogTracker"/> + <see cref="ImeProcessWatcher"/>
/// + <see cref="ImeLogTrackerAdapter"/>.</item>
/// <item><b>StallProbeHost</b> — <see cref="StallProbeCollector"/> +
/// <see cref="StallProbeCollectorAdapter"/>. Owns its 60-s idle-check timer (the
/// collector itself has no timer — it's a pure probe invoked from outside).</item>
/// </list>
/// Optional peripheral hosts (driven by <see cref="CollectorConfiguration"/> toggles):
/// <list type="bullet">
/// <item><b>PerformanceHost</b> — CPU/memory/disk samples.</item>
/// <item><b>AgentSelfMetricsHost</b> — process CPU, memory and HTTP traffic counters.
/// Wires into the <see cref="NetworkMetrics"/> instance created by the Program.cs
/// <see cref="BackendApiClient"/> (the V2 <see cref="Transport.Telemetry.BackendTelemetryUploader"/>
/// has no metrics — accepted M4.5.b tech-debt).</item>
/// </list>
/// </para>
/// <para>
/// <b>ModernDeployment Events-Bridge</b>: the ModernDeploymentTracker lives inside
/// <see cref="EspAndHelloTracker"/>. Its diagnostic log events reach the telemetry spool
/// via <c>onEnrollmentEvent</c>, exactly like any other EspAndHello event. No separate
/// adapter exists (documented in <see cref="IComponentFactory"/> + verified by the
/// <c>Modern_deployment_host_emits_event_bridge_only_no_decision_signal</c> test).
/// </para>
/// </summary>
public sealed class DefaultComponentFactory : IComponentFactory
{
private readonly AgentConfiguration _agentConfig;
private readonly AgentConfigResponse _remoteConfig;
private readonly NetworkMetrics? _networkMetrics;
private readonly string _agentVersion;
private readonly string _stateDirectory;
public DefaultComponentFactory(
AgentConfiguration agentConfig,
AgentConfigResponse remoteConfig,
NetworkMetrics? networkMetrics,
string agentVersion,
string stateDirectory)
{
_agentConfig = agentConfig ?? throw new ArgumentNullException(nameof(agentConfig));
_remoteConfig = remoteConfig ?? throw new ArgumentNullException(nameof(remoteConfig));
_networkMetrics = networkMetrics;
_agentVersion = string.IsNullOrEmpty(agentVersion) ? "unknown" : agentVersion;
_stateDirectory = stateDirectory ?? throw new ArgumentNullException(nameof(stateDirectory));
}
public IReadOnlyList<ICollectorHost> CreateCollectorHosts(
string sessionId,
string tenantId,
AgentLogger logger,
Action<EnrollmentEvent> onEnrollmentEvent,
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
ISignalIngressSink ingress,
IClock clock)
{
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId required.", nameof(sessionId));
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId required.", nameof(tenantId));
if (logger == null) throw new ArgumentNullException(nameof(logger));
if (onEnrollmentEvent == null) throw new ArgumentNullException(nameof(onEnrollmentEvent));
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
var hosts = new List<ICollectorHost>();
var collectors = _remoteConfig.Collectors ?? CollectorConfiguration.CreateDefault();
// ----- Kernel hosts (always-on; they produce decision signals) --------------------
hosts.Add(new EspAndHelloHost(
sessionId: sessionId,
tenantId: tenantId,
onEnrollmentEvent: onEnrollmentEvent,
logger: logger,
ingress: ingress,
clock: clock,
helloWaitTimeoutSeconds: collectors.HelloWaitTimeoutSeconds,
modernDeploymentWatcherEnabled: collectors.ModernDeploymentWatcherEnabled,
modernDeploymentLogLevelMax: collectors.ModernDeploymentLogLevelMax,
modernDeploymentBackfillEnabled: collectors.ModernDeploymentBackfillEnabled,
modernDeploymentBackfillLookbackMinutes: collectors.ModernDeploymentBackfillLookbackMinutes,
modernDeploymentHarmlessEventIds: collectors.ModernDeploymentHarmlessEventIds,
stateDirectory: _stateDirectory));
hosts.Add(new DesktopArrivalHost(logger, ingress, clock));
hosts.Add(new AadJoinHost(logger, ingress, clock));
hosts.Add(new ImeLogHost(
sessionId: sessionId,
tenantId: tenantId,
onEnrollmentEvent: onEnrollmentEvent,
logger: logger,
ingress: ingress,
clock: clock,
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
imeMatchLogPath: _agentConfig.ImeMatchLogPath,
imePatterns: _remoteConfig.ImeLogPatterns,
stateDirectory: _stateDirectory,
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds));
if (collectors.StallProbeEnabled)
{
hosts.Add(new StallProbeHost(
sessionId: sessionId,
tenantId: tenantId,
onEnrollmentEvent: onEnrollmentEvent,
logger: logger,
ingress: ingress,
clock: clock,
thresholdsMinutes: collectors.StallProbeThresholdsMinutes,
traceIndices: collectors.StallProbeTraceIndices,
sources: collectors.StallProbeSources,
sessionStalledAfterProbeIndex: collectors.SessionStalledAfterProbeIndex,
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds));
}
// ----- Peripheral hosts (event-only; driven by remote-config toggles) --------------
if (collectors.EnablePerformanceCollector)
{
hosts.Add(new PerformanceHost(sessionId, tenantId, onEnrollmentEvent, logger,
collectors.PerformanceIntervalSeconds));
}
if (collectors.EnableAgentSelfMetrics && _networkMetrics != null)
{
hosts.Add(new AgentSelfMetricsHost(sessionId, tenantId, onEnrollmentEvent, logger,
_networkMetrics, _agentVersion, collectors.AgentSelfMetricsIntervalSeconds));
}
return hosts;
}
// =====================================================================================
// Hosts
// =====================================================================================
private sealed class EspAndHelloHost : ICollectorHost
{
public string Name => "EspAndHelloTracker";
private readonly EspAndHelloTracker _tracker;
private readonly EspAndHelloTrackerAdapter _adapter;
private int _disposed;
public EspAndHelloHost(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEnrollmentEvent,
AgentLogger logger,
ISignalIngressSink ingress,
IClock clock,
int helloWaitTimeoutSeconds,
bool modernDeploymentWatcherEnabled,
int modernDeploymentLogLevelMax,
bool modernDeploymentBackfillEnabled,
int modernDeploymentBackfillLookbackMinutes,
int[]? modernDeploymentHarmlessEventIds,
string stateDirectory)
{
_tracker = new EspAndHelloTracker(
sessionId: sessionId,
tenantId: tenantId,
onEventCollected: onEnrollmentEvent,
logger: logger,
helloWaitTimeoutSeconds: helloWaitTimeoutSeconds,
modernDeploymentWatcherEnabled: modernDeploymentWatcherEnabled,
modernDeploymentLogLevelMax: modernDeploymentLogLevelMax,
modernDeploymentBackfillEnabled: modernDeploymentBackfillEnabled,
modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes,
stateDirectory: stateDirectory,
modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds);
_adapter = new EspAndHelloTrackerAdapter(_tracker, ingress, clock);
}
public void Start() => _tracker.Start();
public void Stop() => _tracker.Stop();
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
try { _adapter.Dispose(); } catch { }
try { _tracker.Dispose(); } catch { }
}
}
private sealed class DesktopArrivalHost : ICollectorHost
{
public string Name => "DesktopArrivalDetector";
private readonly DesktopArrivalDetector _detector;
private readonly DesktopArrivalDetectorAdapter _adapter;
private int _disposed;
public DesktopArrivalHost(AgentLogger logger, ISignalIngressSink ingress, IClock clock)
{
_detector = new DesktopArrivalDetector(logger);
_adapter = new DesktopArrivalDetectorAdapter(_detector, ingress, clock);
}
public void Start() => _detector.Start();
public void Stop() => _detector.Stop();
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
try { _adapter.Dispose(); } catch { }
try { _detector.Dispose(); } catch { }
}
}
private sealed class AadJoinHost : ICollectorHost
{
public string Name => "AadJoinWatcher";
private readonly AadJoinWatcher _watcher;
private readonly AadJoinWatcherAdapter _adapter;
private int _disposed;
public AadJoinHost(AgentLogger logger, ISignalIngressSink ingress, IClock clock)
{
_watcher = new AadJoinWatcher(logger);
_adapter = new AadJoinWatcherAdapter(_watcher, ingress, clock);
}
public void Start() => _watcher.Start();
public void Stop() => _watcher.Stop();
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
try { _adapter.Dispose(); } catch { }
try { _watcher.Dispose(); } catch { }
}
}
private sealed class ImeLogHost : ICollectorHost
{
public string Name => "ImeLogTracker";
private const string DefaultImeLogFolder = @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs";
private readonly ImeLogTracker _tracker;
private readonly ImeProcessWatcher _processWatcher;
private readonly ImeLogTrackerAdapter _adapter;
private readonly AgentLogger _logger;
private int _disposed;
public ImeLogHost(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEnrollmentEvent,
AgentLogger logger,
ISignalIngressSink ingress,
IClock clock,
string? imeLogPathOverride,
string? imeMatchLogPath,
List<ImeLogPattern>? imePatterns,
string stateDirectory,
IReadOnlyCollection<string>? whiteGloveSealingPatternIds)
{
_logger = logger;
var logFolder = string.IsNullOrEmpty(imeLogPathOverride) ? DefaultImeLogFolder : imeLogPathOverride!;
var expandedMatchLogPath = string.IsNullOrEmpty(imeMatchLogPath)
? null
: Environment.ExpandEnvironmentVariables(imeMatchLogPath);
var patterns = imePatterns ?? new List<ImeLogPattern>();
_tracker = new ImeLogTracker(
logFolder: logFolder,
patterns: patterns,
logger: logger,
matchLogPath: expandedMatchLogPath,
stateDirectory: stateDirectory);
_adapter = new ImeLogTrackerAdapter(_tracker, ingress, clock, whiteGloveSealingPatternIds);
_processWatcher = new ImeProcessWatcher(sessionId, tenantId, onEnrollmentEvent, logger);
}
public void Start()
{
_tracker.Start();
_processWatcher.Start();
}
public void Stop()
{
try { _processWatcher.Dispose(); }
catch (Exception ex) { _logger.Warning($"ImeLogHost: processWatcher dispose failed: {ex.Message}"); }
try { _tracker.Stop(); }
catch (Exception ex) { _logger.Warning($"ImeLogHost: tracker stop failed: {ex.Message}"); }
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
try { _adapter.Dispose(); } catch { }
try { _processWatcher.Dispose(); } catch { }
try { _tracker.Stop(); } catch { }
}
}
private sealed class StallProbeHost : ICollectorHost
{
public string Name => "StallProbeCollector";
private static readonly TimeSpan IdleTickInterval = TimeSpan.FromSeconds(60);
private readonly StallProbeCollector _collector;
private readonly StallProbeCollectorAdapter _adapter;
private readonly AgentLogger _logger;
private Timer? _tickTimer;
private readonly DateTime _startedAtUtc;
private int _disposed;
public StallProbeHost(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEnrollmentEvent,
AgentLogger logger,
ISignalIngressSink ingress,
IClock clock,
int[]? thresholdsMinutes,
int[]? traceIndices,
string[]? sources,
int sessionStalledAfterProbeIndex,
int[]? harmlessModernDeploymentEventIds)
{
_logger = logger;
_collector = new StallProbeCollector(
sessionId: sessionId,
tenantId: tenantId,
onEventCollected: onEnrollmentEvent,
logger: logger,
thresholdsMinutes: thresholdsMinutes ?? new[] { 2, 15, 30, 60, 180 },
traceIndices: traceIndices ?? new[] { 2 },
sources: sources ?? new[] { "provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log" },
sessionStalledAfterProbeIndex: sessionStalledAfterProbeIndex,
harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds);
_adapter = new StallProbeCollectorAdapter(_collector, ingress, clock);
_startedAtUtc = DateTime.UtcNow;
}
public void Start()
{
// The collector has no timer of its own — we drive it with a 60-s idle tick.
_tickTimer = new Timer(
_ => SafeTick(),
state: null,
dueTime: IdleTickInterval,
period: IdleTickInterval);
_logger.Info($"StallProbeHost: started (tick every {IdleTickInterval.TotalSeconds}s).");
}
public void Stop()
{
try
{
_tickTimer?.Dispose();
_tickTimer = null;
}
catch (Exception ex) { _logger.Warning($"StallProbeHost: timer dispose failed: {ex.Message}"); }
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
Stop();
try { _adapter.Dispose(); } catch { }
}
private void SafeTick()
{
try
{
var idleMinutes = (DateTime.UtcNow - _startedAtUtc).TotalMinutes;
_collector.CheckAndRunProbes(idleMinutes);
}
catch (Exception ex)
{
_logger.Error("StallProbeHost: tick failed.", ex);
}
}
}
private sealed class PerformanceHost : ICollectorHost
{
public string Name => "PerformanceCollector";
private readonly PerformanceCollector _collector;
private int _disposed;
public PerformanceHost(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEnrollmentEvent,
AgentLogger logger,
int intervalSeconds)
{
_collector = new PerformanceCollector(sessionId, tenantId, onEnrollmentEvent, logger, intervalSeconds);
}
public void Start() => _collector.Start();
public void Stop() => _collector.Stop();
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
try { _collector.Dispose(); } catch { }
}
}
private sealed class AgentSelfMetricsHost : ICollectorHost
{
public string Name => "AgentSelfMetricsCollector";
private readonly AgentSelfMetricsCollector _collector;
private int _disposed;
public AgentSelfMetricsHost(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEnrollmentEvent,
AgentLogger logger,
NetworkMetrics networkMetrics,
string agentVersion,
int intervalSeconds)
{
_collector = new AgentSelfMetricsCollector(
sessionId, tenantId, onEnrollmentEvent, networkMetrics, logger, agentVersion, intervalSeconds);
}
public void Start() => _collector.Start();
public void Stop() => _collector.Stop();
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
try { _collector.Dispose(); } catch { }
}
}
}
}
@@ -367,7 +367,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
var safeSink = BuildTelemetryEventSink();
_collectorHosts = _componentFactory.CreateCollectorHosts(
_sessionId, _tenantId, _logger, safeSink, _whiteGloveSealingPatternIds);
_sessionId, _tenantId, _logger, safeSink, _whiteGloveSealingPatternIds,
ingress: _ingress, clock: _clock);
foreach (var host in _collectorHosts)
{
try
@@ -2,12 +2,13 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Factory-Seam für Collector-Hosts. Plan §4.x M4.4.5.c.
/// Factory-Seam für Collector-Hosts. Plan §4.x M4.4.5.c + M4.5.b.
/// <para>
/// Der <see cref="EnrollmentOrchestrator"/> delegiert an diese Factory, weil die konkreten
/// Collector-Ctoren heavy Production-Deps (Event-Log-Watcher, WMI, Registry) haben — Tests
@@ -35,12 +36,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// durchgereicht (Sealing-Emission fire-once nur für diese Pattern-IDs). Leer / null
/// = Feature off, M3-kompatibel. Plan §4.x M4.4.5.e.
/// </para>
/// <para>
/// <b>M4.5.b-Extension</b>: <paramref name="ingress"/> und <paramref name="clock"/> werden
/// durchgereicht, damit die Default-Factory SignalAdapter direkt mitwiren kann (Adapters
/// brauchen einen <see cref="ISignalIngressSink"/> + <see cref="IClock"/>, die erst im
/// Orchestrator gebaut werden).
/// </para>
/// </summary>
IReadOnlyList<ICollectorHost> CreateCollectorHosts(
string sessionId,
string tenantId,
AgentLogger logger,
Action<EnrollmentEvent> onEnrollmentEvent,
IReadOnlyCollection<string> whiteGloveSealingPatternIds);
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
ISignalIngressSink ingress,
IClock clock);
}
}
@@ -0,0 +1,110 @@
using System;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Persists the current enrollment SessionId across agent restarts. Plan §4.x M4.5.b.
/// <para>
/// V2 uses a single <c>session.id</c> text file in the agent data directory — simpler than
/// the Legacy <c>SessionPersistence</c> (no <c>.seq</c>, no white-glove marker, no session-age
/// tracking; those live in the new DecisionState snapshot/journal).
/// </para>
/// <para>
/// Write semantics are atomic (write-to-temp + <see cref="File.Replace(string,string,string)"/>) so a
/// power-cut mid-write cannot leave a zero-byte file that later reads as "no session" and
/// trips a fresh SessionId on recovery.
/// </para>
/// </summary>
public sealed class SessionIdPersistence
{
private readonly string _sessionFilePath;
private readonly object _lockObject = new object();
public SessionIdPersistence(string dataDirectory)
{
if (string.IsNullOrWhiteSpace(dataDirectory))
throw new ArgumentNullException(nameof(dataDirectory));
if (!Directory.Exists(dataDirectory))
Directory.CreateDirectory(dataDirectory);
_sessionFilePath = Path.Combine(dataDirectory, "session.id");
}
/// <summary>
/// Returns the persisted SessionId or creates, writes and returns a fresh one.
/// Thread-safe — concurrent callers see the same SessionId.
/// </summary>
public string GetOrCreate(AgentLogger logger = null)
{
lock (_lockObject)
{
try
{
if (File.Exists(_sessionFilePath))
{
var existing = File.ReadAllText(_sessionFilePath).Trim();
if (Guid.TryParse(existing, out _))
{
logger?.Debug($"SessionIdPersistence: resumed existing SessionId={existing}.");
return existing;
}
logger?.Warning($"SessionIdPersistence: session.id contained non-GUID content — regenerating.");
}
}
catch (Exception ex)
{
logger?.Warning($"SessionIdPersistence: failed to read session.id ({ex.Message}) — regenerating.");
}
var fresh = Guid.NewGuid().ToString();
WriteAtomic(fresh, logger);
logger?.Info($"SessionIdPersistence: created new SessionId={fresh}.");
return fresh;
}
}
/// <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}");
}
}
}
private void WriteAtomic(string sessionId, AgentLogger logger)
{
var tempPath = _sessionFilePath + ".tmp";
try
{
File.WriteAllText(tempPath, sessionId);
if (File.Exists(_sessionFilePath))
{
// File.Replace preserves ACLs + is atomic on NTFS.
File.Replace(tempPath, _sessionFilePath, destinationBackupFileName: null);
}
else
{
File.Move(tempPath, _sessionFilePath);
}
}
catch (Exception ex)
{
logger?.Error($"SessionIdPersistence: atomic write failed ({ex.Message}) — falling back to direct write.", ex);
File.WriteAllText(_sessionFilePath, sessionId);
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { }
}
}
}
}
@@ -0,0 +1,75 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Logging;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Resolves the Autopilot device's AAD TenantId from the MDM enrollment registry.
/// Plan §4.x M4.5.b.
/// <para>
/// The MDM enrollment writes one sub-key per enrollment under
/// <c>HKLM\SOFTWARE\Microsoft\Enrollments</c>. The entry with <c>EnrollmentType==6</c>
/// (MDM Intune enrollment) carries <c>AADTenantID</c> — the only value that maps the
/// device back to its Entra ID tenant without a backend round-trip.
/// </para>
/// </summary>
public static class TenantIdResolver
{
private const string EnrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
private const int IntuneMdmEnrollmentType = 6;
/// <summary>
/// Walks the enrollments subtree and returns the first <c>AADTenantID</c> tagged with
/// the Intune MDM enrollment type. Returns <c>null</c> when the device is not MDM-enrolled
/// or the registry key is inaccessible. Never throws.
/// </summary>
public static string ResolveFromEnrollmentRegistry(AgentLogger logger = null)
{
try
{
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(EnrollmentsKeyPath))
{
if (enrollmentsKey == null)
{
logger?.Warning($"TenantIdResolver: {EnrollmentsKeyPath} not found — device likely not MDM-enrolled.");
return null;
}
foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames())
{
using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid))
{
if (enrollmentKey == null) continue;
var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
if (enrollmentType == null) continue;
int typeValue;
try { typeValue = Convert.ToInt32(enrollmentType); }
catch { continue; }
if (typeValue != IntuneMdmEnrollmentType) continue;
var tenantId = enrollmentKey.GetValue("AADTenantID");
var resolved = tenantId?.ToString();
if (!string.IsNullOrEmpty(resolved))
{
logger?.Debug($"TenantIdResolver: resolved AADTenantID={resolved} from enrollment {enrollmentGuid}.");
return resolved;
}
}
}
}
logger?.Warning("TenantIdResolver: no Intune MDM enrollment (EnrollmentType=6) with AADTenantID found.");
return null;
}
catch (Exception ex)
{
logger?.Error("TenantIdResolver: registry probe failed.", ex);
return null;
}
}
}
}
+332 -17
View File
@@ -1,21 +1,49 @@
using System;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Agent.V2
{
/// <summary>
/// V2-Agent entry point — M2 skeleton.
/// V2-Agent entry point. Plan §4.x M4.5.b.
/// <para>
/// Plan §4 M2 release gate: "V2-Agent-Exe startet + terminiert sauber (ohne echtes Enrollment).
/// Harness durchläuft leeren Signal-Stream deterministisch."
/// </para>
/// <para>
/// Real startup wiring (orchestrator, collectors, signal ingress, transport) lands in M4.
/// Start sequence:
/// </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>
/// </list>
/// </summary>
public static class Program
{
private const string DefaultStateDirectory = @"%ProgramData%\AutopilotMonitor";
private const string DefaultLogDirectory = @"%ProgramData%\AutopilotMonitor\Logs";
private const string DefaultV2StateSubdirectory = "V2";
private const string DefaultTransportSubdirectory = "V2Transport";
public static int Main(string[] args)
{
if (args.Contains("--help") || args.Contains("-h") || args.Contains("-?"))
@@ -30,29 +58,316 @@ namespace AutopilotMonitor.Agent.V2
return 0;
}
// M2 skeleton: exits cleanly. No enrollment logic, no collectors, no IO.
// Additional modes (--install, --run-gather-rules, --run-ime-matching, normal runtime)
// will be added as the corresponding V2 subsystems come online in M3–M6.
Console.Out.WriteLine("AutopilotMonitor.Agent.V2 skeleton — M2. No operational mode wired yet.");
Console.Out.WriteLine("Run with --help for available flags.");
var consoleMode = args.Contains("--console") || Environment.UserInteractive;
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
{
return RunAgent(args, logger, dataDirectory, consoleMode);
}
catch (Exception ex)
{
logger.Error("V2 agent startup failed.", ex);
if (consoleMode) Console.Error.WriteLine($"FATAL: {ex.Message}");
return 1;
}
}
// ---------------------------------------------------------------- Orchestration
private static int RunAgent(string[] args, AgentLogger logger, string dataDirectory, bool consoleMode)
{
if (!Directory.Exists(dataDirectory)) Directory.CreateDirectory(dataDirectory);
var tenantId = TenantIdResolver.ResolveFromEnrollmentRegistry(logger);
if (string.IsNullOrEmpty(tenantId))
{
logger.Error("V2 agent cannot start: TenantId could not be resolved from the enrollment registry. Device is not MDM-enrolled.");
return 2;
}
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);
if (agentConfig.AwaitEnrollment)
{
logger.Info($"--await-enrollment: polling for MDM certificate (timeout: {agentConfig.AwaitEnrollmentTimeoutMinutes}min).");
using (var cts = new CancellationTokenSource())
{
var cert = EnrollmentAwaiter
.WaitForMdmCertificateAsync(
thumbprint: null,
timeoutMinutes: agentConfig.AwaitEnrollmentTimeoutMinutes,
logger: logger,
cancellationToken: cts.Token)
.GetAwaiter().GetResult();
if (cert == null)
{
logger.Error("Await-enrollment: timed out waiting for MDM certificate — exiting.");
return 3;
}
}
}
var backendApiClient = new BackendApiClient(
baseUrl: agentConfig.ApiBaseUrl,
configuration: agentConfig,
logger: logger,
agentVersion: GetAgentVersion());
var remoteConfigService = new RemoteConfigService(backendApiClient, tenantId, logger);
var remoteConfig = remoteConfigService.FetchConfigAsync().GetAwaiter().GetResult();
logger.SetLogLevel(agentConfig.LogLevel);
HttpClient mtlsHttpClient;
BackendTelemetryUploader uploader;
try
{
mtlsHttpClient = MtlsHttpClientFactory.Create(
resolver: new DefaultCertificateResolver(),
logger: logger);
}
catch (InvalidOperationException ex)
{
logger.Error("mTLS HttpClient creation failed — cannot upload telemetry.", ex);
return 4;
}
try
{
var hardware = HardwareInfo.GetHardwareInfo(logger);
uploader = new BackendTelemetryUploader(
httpClient: mtlsHttpClient,
baseUrl: agentConfig.ApiBaseUrl,
tenantId: tenantId,
manufacturer: hardware.Manufacturer,
model: hardware.Model,
serialNumber: hardware.SerialNumber,
bootstrapToken: agentConfig.UseBootstrapTokenAuth ? agentConfig.BootstrapToken : null,
agentVersion: GetAgentVersion());
}
catch (Exception ex)
{
logger.Error("BackendTelemetryUploader construction failed.", ex);
return 5;
}
var stateSubdir = Path.Combine(dataDirectory, DefaultV2StateSubdirectory);
var transportDir = Path.Combine(dataDirectory, DefaultTransportSubdirectory);
var classifiers = new IClassifier[]
{
new WhiteGloveSealingClassifier(),
new WhiteGlovePart2CompletionClassifier(),
};
var componentFactory = new DefaultComponentFactory(
agentConfig: agentConfig,
remoteConfig: remoteConfig,
networkMetrics: backendApiClient.NetworkMetrics,
agentVersion: GetAgentVersion(),
stateDirectory: stateSubdir);
var whiteGloveSealingPatternIds = (System.Collections.Generic.IReadOnlyCollection<string>)remoteConfig.WhiteGloveSealingPatternIds
?? Array.Empty<string>();
using (var orchestrator = new EnrollmentOrchestrator(
sessionId: sessionId,
tenantId: tenantId,
stateDirectory: stateSubdir,
transportDirectory: transportDir,
clock: SystemClock.Instance,
logger: logger,
uploader: uploader,
classifiers: classifiers,
componentFactory: componentFactory,
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds))
{
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();
};
EventHandler processExitHandler = (s, e) =>
{
logger.Info("ProcessExit — initiating graceful shutdown.");
shutdown.Set();
};
Console.CancelKeyPress += cancelHandler;
AppDomain.CurrentDomain.ProcessExit += processExitHandler;
try
{
orchestrator.Start();
logger.Info($"V2 agent runtime ready (session={sessionId}, tenant={tenantId}).");
if (consoleMode)
{
Console.Out.WriteLine("AutopilotMonitor.Agent.V2 running. Press Ctrl+C to stop.");
}
shutdown.Wait();
}
finally
{
Console.CancelKeyPress -= cancelHandler;
AppDomain.CurrentDomain.ProcessExit -= processExitHandler;
try { orchestrator.Stop(); }
catch (Exception ex) { logger.Error("Orchestrator stop failed.", ex); }
try { mtlsHttpClient.Dispose(); } catch { }
try { backendApiClient.Dispose(); } catch { }
}
}
}
logger.Info("AutopilotMonitor.Agent.V2 stopped cleanly.");
return 0;
}
// ---------------------------------------------------------------- Configuration
private static AgentConfiguration BuildAgentConfiguration(string[] args, string tenantId, string sessionId)
{
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 rebootOnComplete = args.Contains("--reboot-on-complete");
var disableGeoLocation = args.Contains("--disable-geolocation");
var keepLogFile = args.Contains("--keep-logfile");
var awaitTimeoutRaw = GetArgValue(args, "--await-enrollment-timeout");
var awaitTimeoutMinutes = 480;
if (!string.IsNullOrEmpty(awaitTimeoutRaw) && int.TryParse(awaitTimeoutRaw, out var parsedTimeout))
awaitTimeoutMinutes = parsedTimeout;
var useBootstrapTokenAuth = !string.IsNullOrEmpty(bootstrapToken);
return new AgentConfiguration
{
ApiBaseUrl = apiBaseUrl,
SessionId = sessionId,
TenantId = tenantId,
SpoolDirectory = Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory),
LogDirectory = Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
UploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds,
MaxBatchSize = Constants.MaxBatchSize,
LogLevel = AgentLogLevel.Info,
UseClientCertAuth = !useBootstrapTokenAuth,
BootstrapToken = bootstrapToken,
UseBootstrapTokenAuth = useBootstrapTokenAuth,
AwaitEnrollment = awaitEnrollment,
AwaitEnrollmentTimeoutMinutes = awaitTimeoutMinutes,
RebootOnComplete = rebootOnComplete,
EnableGeoLocation = !disableGeoLocation,
ImeLogPathOverride = imeLogPathOverride,
ImeMatchLogPath = imeMatchLogPath,
KeepLogFile = keepLogFile,
CommandLineArgs = FormatArgsForLog(args),
};
}
private 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++)
{
foreach (var name in names)
{
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
return args[i + 1];
}
}
return null;
}
private static string FormatArgsForLog(string[] args)
{
if (args == null || args.Length == 0) return string.Empty;
var parts = new System.Collections.Generic.List<string>(args.Length);
for (int i = 0; i < args.Length; i++)
{
var a = args[i];
if (string.Equals(a, "--bootstrap-token", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
parts.Add(a);
parts.Add("[redacted]");
i++;
continue;
}
parts.Add(a);
}
return string.Join(" ", parts);
}
// ---------------------------------------------------------------- Info
private static void PrintUsage()
{
Console.Out.WriteLine("AutopilotMonitor.Agent.V2 — Autopilot-Monitor V2 agent (kernel-only refactor).");
Console.Out.WriteLine("AutopilotMonitor.Agent.V2 — Autopilot-Monitor V2 agent.");
Console.Out.WriteLine();
Console.Out.WriteLine("Usage:");
Console.Out.WriteLine(" AutopilotMonitor.Agent.V2.exe [--help | --version]");
Console.Out.WriteLine(" AutopilotMonitor.Agent.V2.exe [options]");
Console.Out.WriteLine();
Console.Out.WriteLine("Operational modes (install, gather-rules, ime-matching, runtime) are added in M3–M6.");
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");
}
private static void PrintVersion()
{
var asm = Assembly.GetExecutingAssembly();
var version = asm.GetName().Version?.ToString() ?? "0.0.0.0";
Console.Out.WriteLine(version);
Console.Out.WriteLine(GetAgentVersion());
}
private static string GetAgentVersion()
{
try
{
var asm = Assembly.GetExecutingAssembly();
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(info)) return info;
var file = asm.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version;
if (!string.IsNullOrWhiteSpace(file)) return file;
return asm.GetName().Version?.ToString() ?? "0.0.0.0";
}
catch
{
return "0.0.0.0";
}
}
}
}
@@ -1 +1 @@
15
24
@@ -1 +1 @@
1057
1064