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);
}
}