From baf8ece79c01b22fa9eefe1d402f3a397ae744e4 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 22 May 2026 09:40:58 +0200 Subject: [PATCH] feat(agent/v2,decision): include RealmJoin.exe ProductVersion in realmjoin_detected event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the RJ host version alongside the existing detection signal so operators can see at a glance "RJ detected, version x.y.z" without having to cross-reference package logs. Read path (RealmJoinInfo.TryReadRealmJoinProductVersion): - FileVersionInfo.GetVersionInfo against C:\Program Files\RealmJoin\RealmJoin.exe. - (x86) fallback path probed when the 64-bit install is absent. - Defensive try/catch + File.Exists pre-check: a missing/locked/inaccessible binary returns null. Version is observability-only and must never block the detection signal — the realmjoin_detected event still emits without version. - Called exactly ONCE per session, on the first Detected fire-once boundary. Wire: - RealmJoinDetectedEventArgs now carries an optional ProductVersion string. - RealmJoinPayloadKeys.ProductVersion ("productVersion") plumbed through both the DecisionSignal payload and the InformationalEvent data dict + message tail. - RealmJoinFacts.ProductVersion (SignalFact, set-once) recorded on the reducer side so the enrollment_complete audit trail picks it up as realmjoinProductVersion via DecisionAuditTrailBuilder. Tests: +2 adapter tests (with-version + without-version) — 12/12 green on the RealmJoin + SID suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../RealmJoinWatcherAdapterTests.cs | 40 +++++++++++++++++++ .../Enrollment/SystemSignals/RealmJoinInfo.cs | 35 ++++++++++++++++ .../SystemSignals/RealmJoinWatcher.cs | 21 ++++++++-- .../SignalAdapters/RealmJoinWatcherAdapter.cs | 18 +++++++-- .../Engine/DecisionAuditTrailBuilder.cs | 4 ++ .../Engine/DecisionEngine.RealmJoin.cs | 7 ++++ .../State/RealmJoinFacts.cs | 37 +++++++++++++++++ 7 files changed, 156 insertions(+), 6 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs index 2c23887f..eeef40e1 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs @@ -63,6 +63,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters Assert.Contains("HKLM", info.Payload["registryKey"]); } + [Fact] + public void TriggerDetectedFromTest_includes_RealmJoin_ProductVersion_when_provided() + { + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerDetectedFromTest(phase: 100, productVersion: "3.5.21.0"); + + var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinDetected); + Assert.Equal("3.5.21.0", decision.Payload![DecisionEngine.RealmJoinPayloadKeys.ProductVersion]); + + var info = f.Ingress.Posted.Single(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.RealmJoinDetected); + Assert.Equal("3.5.21.0", info.Payload!["productVersion"]); + Assert.Contains("version=3.5.21.0", info.Payload[SignalPayloadKeys.Message]); + } + + [Fact] + public void TriggerDetectedFromTest_omits_ProductVersion_when_null_or_empty() + { + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerDetectedFromTest(phase: 100, productVersion: null); + + var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinDetected); + Assert.False(decision.Payload!.ContainsKey(DecisionEngine.RealmJoinPayloadKeys.ProductVersion)); + + var info = f.Ingress.Posted.Single(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.RealmJoinDetected); + Assert.False(info.Payload!.ContainsKey("productVersion")); + Assert.DoesNotContain("version=", info.Payload[SignalPayloadKeys.Message]); + } + [Fact] public void TriggerResolvedFromTest_emits_RealmJoinResolved_signal_and_informational_event() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs index d6cfa8a1..a261465d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs @@ -1,7 +1,9 @@ #nullable enable using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; +using System.IO; using Microsoft.Win32; namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals @@ -64,6 +66,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals // Resolved-phase value per the RJ enum (RealmJoin.Core.SoftwarePackaging.DeploymentPhase). public const int PhaseCompletedFirstDeployment = 110; + /// Canonical 64-bit install path for the RJ host executable. + public const string RealmJoinExePath = @"C:\Program Files\RealmJoin\RealmJoin.exe"; + + /// Defensive fallback for legacy 32-bit installs. + public const string RealmJoinExePathX86 = @"C:\Program Files (x86)\RealmJoin\RealmJoin.exe"; + + /// + /// Best-effort read of RealmJoin.exe's ProductVersion via + /// . Wrapped in a defensive try/catch so a + /// missing/locked/inaccessible binary returns null instead of throwing — + /// version is observability-only and must never block the detection signal. Probes the + /// canonical 64-bit path first, then the (x86) fallback. + /// + public static string? TryReadRealmJoinProductVersion() + { + return TryReadVersion(RealmJoinExePath) ?? TryReadVersion(RealmJoinExePathX86); + } + + private static string? TryReadVersion(string path) + { + try + { + if (!File.Exists(path)) return null; + var info = FileVersionInfo.GetVersionInfo(path); + var version = info.ProductVersion; + return string.IsNullOrWhiteSpace(version) ? info.FileVersion : version; + } + catch + { + return null; + } + } + /// /// Read the DeploymentPhase DWORD from the Parameters key. Returns null when the /// key/value does not exist or is unreadable. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs index 99e9811d..53b72109 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs @@ -12,7 +12,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals public sealed class RealmJoinDetectedEventArgs : EventArgs { public int DeploymentPhase { get; } - public RealmJoinDetectedEventArgs(int deploymentPhase) { DeploymentPhase = deploymentPhase; } + /// + /// ProductVersion read from C:\Program Files\RealmJoin\RealmJoin.exe + /// (or the x86 fallback). null when the binary is missing, locked, or read + /// fails — version is observability-only and never blocks detection. + /// + public string? ProductVersion { get; } + + public RealmJoinDetectedEventArgs(int deploymentPhase, string? productVersion = null) + { + DeploymentPhase = deploymentPhase; + ProductVersion = productVersion; + } } public sealed class RealmJoinResolvedEventArgs : EventArgs @@ -483,8 +494,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals if (fireDetected) { var initial = phase ?? 0; - _logger.Info($"RealmJoinWatcher: RJ detected (phase={initial})"); - try { RealmJoinDetected?.Invoke(this, new RealmJoinDetectedEventArgs(initial)); } + // Read RJ ProductVersion best-effort (fail-soft to null) ONLY when the Detected + // event actually fires — single read per session lifetime keeps any potential + // file-system cost to a single hit. + var productVersion = RealmJoinInfo.TryReadRealmJoinProductVersion(); + _logger.Info($"RealmJoinWatcher: RJ detected (phase={initial}, productVersion={productVersion ?? ""})"); + try { RealmJoinDetected?.Invoke(this, new RealmJoinDetectedEventArgs(initial, productVersion)); } catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinDetected handler threw", ex); } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs index e125d92b..d656e5d1 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/RealmJoinWatcherAdapter.cs @@ -51,7 +51,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters } // Test seams - internal void TriggerDetectedFromTest(int phase) => OnDetected(this, new RealmJoinDetectedEventArgs(phase)); + internal void TriggerDetectedFromTest(int phase, string? productVersion = null) => + OnDetected(this, new RealmJoinDetectedEventArgs(phase, productVersion)); internal void TriggerResolvedFromTest(int phase) => OnResolved(this, new RealmJoinResolvedEventArgs(phase)); internal void TriggerPhaseChangedFromTest(int prev, int curr) => OnPhaseChanged(this, new RealmJoinPhaseChangedEventArgs(prev, curr)); internal void TriggerPackageStartedFromTest(string scope, RealmJoinPackageSnapshot snap) => OnPackageStarted(this, new RealmJoinPackageEventArgs(scope, snap)); @@ -63,12 +64,18 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = e.DeploymentPhase.ToString(), }; + if (!string.IsNullOrEmpty(e.ProductVersion)) + { + payload[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = e.ProductVersion!; + } _ingress.Post( kind: DecisionSignalKind.RealmJoinDetected, occurredAtUtc: _clock.UtcNow, sourceOrigin: SourceLabel, - evidence: BuildEvidence("realmjoin-detected-v1", $"RealmJoin Parameters key observed (phase={e.DeploymentPhase})"), + evidence: BuildEvidence( + "realmjoin-detected-v1", + $"RealmJoin Parameters key observed (phase={e.DeploymentPhase}, productVersion={e.ProductVersion ?? ""})"), payload: payload); var data = new Dictionary(StringComparer.Ordinal) @@ -76,10 +83,15 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters ["deploymentPhase"] = e.DeploymentPhase.ToString(), ["registryKey"] = RegistryKeyHint, }; + if (!string.IsNullOrEmpty(e.ProductVersion)) + { + data["productVersion"] = e.ProductVersion!; + } + var versionTail = string.IsNullOrEmpty(e.ProductVersion) ? string.Empty : $", version={e.ProductVersion}"; _post.Emit( eventType: SharedConstants.EventTypes.RealmJoinDetected, source: SourceLabel, - message: $"RealmJoin deployment detected (phase={e.DeploymentPhase})", + message: $"RealmJoin deployment detected (phase={e.DeploymentPhase}{versionTail})", severity: EventSeverity.Info, immediateUpload: true, data: data, diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs index 26c1b0bc..26ea8bda 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs @@ -143,6 +143,10 @@ namespace AutopilotMonitor.DecisionCore.Engine { data["realmjoinResolvedUtc"] = FormatUtc(postState.RealmJoinFacts.ResolvedUtc.Value); } + if (postState.RealmJoinFacts.ProductVersion != null) + { + data["realmjoinProductVersion"] = postState.RealmJoinFacts.ProductVersion.Value; + } data["realmjoinPackages"] = BuildRealmJoinPackageSummary(postState.RealmJoinFacts.Packages); } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs index b0a105f1..d7b53712 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs @@ -31,6 +31,8 @@ namespace AutopilotMonitor.DecisionCore.Engine public const string Scope = "scope"; // "machine" | "user" public const string Success = "success"; // "true" | "false" public const string LastExitCode = "lastExitCode"; + // Set on RealmJoinDetected by the agent adapter from RealmJoin.exe ProductVersion. + public const string ProductVersion = "productVersion"; } /// @@ -77,11 +79,16 @@ namespace AutopilotMonitor.DecisionCore.Engine var alreadyDetected = state.RealmJoinFacts.DetectedUtc != null; var phase = TryReadPhase(signal); + var productVersion = TryReadString(signal, RealmJoinPayloadKeys.ProductVersion); var updatedFacts = state.RealmJoinFacts.WithDetected(signal.OccurredAtUtc, signal.SessionSignalOrdinal); if (phase.HasValue) { updatedFacts = updatedFacts.WithLastPhase(phase.Value, signal.SessionSignalOrdinal); } + if (!string.IsNullOrEmpty(productVersion)) + { + updatedFacts = updatedFacts.WithProductVersion(productVersion!, signal.SessionSignalOrdinal); + } builder.RealmJoinFacts = updatedFacts; var effects = Array.Empty(); diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs index 83e38fd2..f29d5c1b 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs @@ -32,6 +32,7 @@ namespace AutopilotMonitor.DecisionCore.State lastDeploymentPhase: null, outcome: null, selfDeployingDeferredCompletion: null, + productVersion: null, packages: Array.Empty()); public RealmJoinFacts( @@ -40,6 +41,7 @@ namespace AutopilotMonitor.DecisionCore.State SignalFact? lastDeploymentPhase, SignalFact? outcome, SignalFact? selfDeployingDeferredCompletion, + SignalFact? productVersion, IReadOnlyList packages) { DetectedUtc = detectedUtc; @@ -47,6 +49,7 @@ namespace AutopilotMonitor.DecisionCore.State LastDeploymentPhase = lastDeploymentPhase; Outcome = outcome; SelfDeployingDeferredCompletion = selfDeployingDeferredCompletion; + ProductVersion = productVersion; Packages = packages ?? Array.Empty(); } @@ -70,6 +73,13 @@ namespace AutopilotMonitor.DecisionCore.State /// public SignalFact? SelfDeployingDeferredCompletion { get; } + /// + /// ProductVersion read from C:\Program Files\RealmJoin\RealmJoin.exe at + /// detection time. Observability-only — never gates a decision. Null when the binary + /// was missing or unreadable. + /// + public SignalFact? ProductVersion { get; } + /// Tracked per-package install rows (machine + user scope combined). public IReadOnlyList Packages { get; } @@ -82,6 +92,26 @@ namespace AutopilotMonitor.DecisionCore.State lastDeploymentPhase: LastDeploymentPhase, outcome: Outcome, selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + productVersion: ProductVersion, + packages: Packages); + } + + /// + /// Set-once observability fact carrying the RealmJoin.exe ProductVersion. No-op + /// when is empty or already set. Pure metadata — + /// never participates in a gate decision. + /// + public RealmJoinFacts WithProductVersion(string productVersion, long sourceSignalOrdinal) + { + if (ProductVersion != null) return this; + if (string.IsNullOrEmpty(productVersion)) return this; + return new RealmJoinFacts( + detectedUtc: DetectedUtc, + resolvedUtc: ResolvedUtc, + lastDeploymentPhase: LastDeploymentPhase, + outcome: Outcome, + selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + productVersion: new SignalFact(productVersion, sourceSignalOrdinal), packages: Packages); } @@ -94,6 +124,7 @@ namespace AutopilotMonitor.DecisionCore.State lastDeploymentPhase: new SignalFact(phase, sourceSignalOrdinal), outcome: new SignalFact(OutcomeResolved, sourceSignalOrdinal), selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + productVersion: ProductVersion, packages: Packages); } @@ -106,6 +137,7 @@ namespace AutopilotMonitor.DecisionCore.State lastDeploymentPhase: new SignalFact(phase, sourceSignalOrdinal), outcome: Outcome, selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + productVersion: ProductVersion, packages: Packages); } @@ -118,6 +150,7 @@ namespace AutopilotMonitor.DecisionCore.State lastDeploymentPhase: LastDeploymentPhase, outcome: new SignalFact(OutcomeTimeout, sourceSignalOrdinal), selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + productVersion: ProductVersion, packages: Packages); } @@ -130,6 +163,7 @@ namespace AutopilotMonitor.DecisionCore.State lastDeploymentPhase: LastDeploymentPhase, outcome: Outcome, selfDeployingDeferredCompletion: new SignalFact(true, sourceSignalOrdinal), + productVersion: ProductVersion, packages: Packages); } @@ -148,6 +182,7 @@ namespace AutopilotMonitor.DecisionCore.State lastDeploymentPhase: LastDeploymentPhase, outcome: Outcome, selfDeployingDeferredCompletion: null, + productVersion: ProductVersion, packages: Packages); } @@ -201,6 +236,7 @@ namespace AutopilotMonitor.DecisionCore.State lastDeploymentPhase: LastDeploymentPhase, outcome: Outcome, selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + productVersion: ProductVersion, packages: copy); } @@ -268,6 +304,7 @@ namespace AutopilotMonitor.DecisionCore.State lastDeploymentPhase: LastDeploymentPhase, outcome: Outcome, selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + productVersion: ProductVersion, packages: copy); } }