From 0ee68c6192a97d9c2af05b84cf532f2ab7728ec0 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 20 Apr 2026 23:48:24 +0200 Subject: [PATCH] feat(agent-v2): M4.5.b runtime entry + DefaultComponentFactory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../EnrollmentOrchestratorEventBridgeTests.cs | 10 +- ...lmentOrchestratorWhiteGlovePatternTests.cs | 5 +- .../Security/SessionIdPersistenceTests.cs | 110 ++++ .../Orchestration/DefaultComponentFactory.cs | 474 ++++++++++++++++++ .../Orchestration/EnrollmentOrchestrator.cs | 3 +- .../Orchestration/IComponentFactory.cs | 13 +- .../Security/SessionIdPersistence.cs | 110 ++++ .../Security/TenantIdResolver.cs | 75 +++ .../AutopilotMonitor.Agent.V2/Program.cs | 349 ++++++++++++- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- 11 files changed, 1129 insertions(+), 24 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs index 1cf012ec..67b6eac8 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs @@ -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? CapturedWhiteGloveSealingPatternIds { get; private set; } + public ISignalIngressSink? CapturedIngress { get; private set; } + public IClock? CapturedClock { get; private set; } + public IReadOnlyList CreateCollectorHosts( string sessionId, string tenantId, AgentLogger logger, Action onEnrollmentEvent, - IReadOnlyCollection whiteGloveSealingPatternIds) + IReadOnlyCollection whiteGloveSealingPatternIds, + ISignalIngressSink ingress, + IClock clock) { CapturedSink = onEnrollmentEvent; CapturedWhiteGloveSealingPatternIds = whiteGloveSealingPatternIds; + CapturedIngress = ingress; + CapturedClock = clock; foreach (var name in HostNames) { Hosts.Add(new FakeCollectorHost(name, onEnrollmentEvent)); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs index 26a44560..5663a9b2 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs @@ -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 onEnrollmentEvent, - IReadOnlyCollection whiteGloveSealingPatternIds) + IReadOnlyCollection whiteGloveSealingPatternIds, + ISignalIngressSink ingress, + IClock clock) { CapturedPatternIds = whiteGloveSealingPatternIds; return Array.Empty(); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs new file mode 100644 index 00000000..30d159bd --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs @@ -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(() => new SessionIdPersistence(null!)); + Assert.Throws(() => new SessionIdPersistence("")); + Assert.Throws(() => 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); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs new file mode 100644 index 00000000..3f2a02a0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -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 +{ + /// + /// Production . Plan §4.x M4.5.b. + /// + /// Builds the real Collector-Hosts for the V2 agent runtime: + /// + /// EspAndHelloHost — coordinator + /// (internally aggregates HelloTracker + ShellCoreTracker + ProvisioningStatusTracker + /// + ModernDeploymentTracker) wired via . + /// 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). + /// DesktopArrivalHost — + . + /// AadJoinHost — + . + /// ImeLogHost — + + /// + . + /// StallProbeHost — + + /// . Owns its 60-s idle-check timer (the + /// collector itself has no timer — it's a pure probe invoked from outside). + /// + /// Optional peripheral hosts (driven by toggles): + /// + /// PerformanceHost — CPU/memory/disk samples. + /// AgentSelfMetricsHost — process CPU, memory and HTTP traffic counters. + /// Wires into the instance created by the Program.cs + /// (the V2 + /// has no metrics — accepted M4.5.b tech-debt). + /// + /// + /// + /// ModernDeployment Events-Bridge: the ModernDeploymentTracker lives inside + /// . Its diagnostic log events reach the telemetry spool + /// via onEnrollmentEvent, exactly like any other EspAndHello event. No separate + /// adapter exists (documented in + verified by the + /// Modern_deployment_host_emits_event_bridge_only_no_decision_signal test). + /// + /// + 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 CreateCollectorHosts( + string sessionId, + string tenantId, + AgentLogger logger, + Action onEnrollmentEvent, + IReadOnlyCollection 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(); + 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 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 onEnrollmentEvent, + AgentLogger logger, + ISignalIngressSink ingress, + IClock clock, + string? imeLogPathOverride, + string? imeMatchLogPath, + List? imePatterns, + string stateDirectory, + IReadOnlyCollection? whiteGloveSealingPatternIds) + { + _logger = logger; + var logFolder = string.IsNullOrEmpty(imeLogPathOverride) ? DefaultImeLogFolder : imeLogPathOverride!; + var expandedMatchLogPath = string.IsNullOrEmpty(imeMatchLogPath) + ? null + : Environment.ExpandEnvironmentVariables(imeMatchLogPath); + var patterns = imePatterns ?? new List(); + + _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 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 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 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 { } + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs index dac97082..0a48bcdb 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs @@ -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 diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IComponentFactory.cs index 0412ca0f..fd60fd64 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IComponentFactory.cs @@ -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 { /// - /// 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. /// /// Der 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. /// + /// + /// M4.5.b-Extension: und werden + /// durchgereicht, damit die Default-Factory SignalAdapter direkt mitwiren kann (Adapters + /// brauchen einen + , die erst im + /// Orchestrator gebaut werden). + /// /// IReadOnlyList CreateCollectorHosts( string sessionId, string tenantId, AgentLogger logger, Action onEnrollmentEvent, - IReadOnlyCollection whiteGloveSealingPatternIds); + IReadOnlyCollection whiteGloveSealingPatternIds, + ISignalIngressSink ingress, + IClock clock); } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs new file mode 100644 index 00000000..831937df --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs @@ -0,0 +1,110 @@ +using System; +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Persists the current enrollment SessionId across agent restarts. Plan §4.x M4.5.b. + /// + /// V2 uses a single session.id text file in the agent data directory — simpler than + /// the Legacy SessionPersistence (no .seq, no white-glove marker, no session-age + /// tracking; those live in the new DecisionState snapshot/journal). + /// + /// + /// Write semantics are atomic (write-to-temp + ) 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. + /// + /// + 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"); + } + + /// + /// Returns the persisted SessionId or creates, writes and returns a fresh one. + /// Thread-safe — concurrent callers see the same SessionId. + /// + 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; + } + } + + /// Deletes the persisted SessionId. Next starts a fresh session. + 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 { } + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs new file mode 100644 index 00000000..eb4b5198 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs @@ -0,0 +1,75 @@ +using System; +using AutopilotMonitor.Agent.V2.Core.Logging; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Resolves the Autopilot device's AAD TenantId from the MDM enrollment registry. + /// Plan §4.x M4.5.b. + /// + /// The MDM enrollment writes one sub-key per enrollment under + /// HKLM\SOFTWARE\Microsoft\Enrollments. The entry with EnrollmentType==6 + /// (MDM Intune enrollment) carries AADTenantID — the only value that maps the + /// device back to its Entra ID tenant without a backend round-trip. + /// + /// + public static class TenantIdResolver + { + private const string EnrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments"; + private const int IntuneMdmEnrollmentType = 6; + + /// + /// Walks the enrollments subtree and returns the first AADTenantID tagged with + /// the Intune MDM enrollment type. Returns null when the device is not MDM-enrolled + /// or the registry key is inaccessible. Never throws. + /// + 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; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index 2070fa09..be408144 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -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 { /// - /// V2-Agent entry point — M2 skeleton. + /// V2-Agent entry point. Plan §4.x M4.5.b. /// - /// Plan §4 M2 release gate: "V2-Agent-Exe startet + terminiert sauber (ohne echtes Enrollment). - /// Harness durchläuft leeren Signal-Stream deterministisch." - /// - /// - /// Real startup wiring (orchestrator, collectors, signal ingress, transport) lands in M4. + /// Start sequence: /// + /// + /// Parse CLI args (--help, --version, --console, --new-session, + /// --api-url, --bootstrap-token, --await-enrollment, --ime-log-path, + /// --ime-match-log) + /// Initialise on %ProgramData%\AutopilotMonitor\Logs + /// Resolve TenantId via + /// Load/create SessionId via + /// (Optional) Wait for MDM certificate in await-enrollment mode + /// Build mTLS- via + /// Fetch remote config via + + /// Wire for telemetry batch uploads + /// Construct + + /// orchestrator.Start() and wait for Ctrl+C / + /// orchestrator.Stop(), return 0 + /// /// 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)remoteConfig.WhiteGloveSealingPatternIds + ?? Array.Empty(); + + 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(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 Override backend base URL (alias: --backend-api)"); + Console.Out.WriteLine(" --bootstrap-token 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 Await-enrollment timeout (default: 480min)"); + Console.Out.WriteLine(" --ime-log-path Override IME log folder (testing/replay)"); + Console.Out.WriteLine(" --ime-match-log 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()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(info)) return info; + + var file = asm.GetCustomAttribute()?.Version; + if (!string.IsNullOrWhiteSpace(file)) return file; + + return asm.GetName().Version?.ToString() ?? "0.0.0.0"; + } + catch + { + return "0.0.0.0"; + } } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 60d3b2f4..a45fd52c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -15 +24 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index c500a5fe..373aef1e 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1057 +1064