mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
feat(agent/v2,decision): include RealmJoin.exe ProductVersion in realmjoin_detected event
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<string>, 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) <noreply@anthropic.com>
This commit is contained in:
+40
@@ -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()
|
||||
{
|
||||
|
||||
+35
@@ -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;
|
||||
|
||||
/// <summary>Canonical 64-bit install path for the RJ host executable.</summary>
|
||||
public const string RealmJoinExePath = @"C:\Program Files\RealmJoin\RealmJoin.exe";
|
||||
|
||||
/// <summary>Defensive fallback for legacy 32-bit installs.</summary>
|
||||
public const string RealmJoinExePathX86 = @"C:\Program Files (x86)\RealmJoin\RealmJoin.exe";
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort read of <c>RealmJoin.exe</c>'s ProductVersion via
|
||||
/// <see cref="FileVersionInfo.GetVersionInfo"/>. Wrapped in a defensive try/catch so a
|
||||
/// missing/locked/inaccessible binary returns <c>null</c> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the <c>DeploymentPhase</c> DWORD from the Parameters key. Returns null when the
|
||||
/// key/value does not exist or is unreadable.
|
||||
|
||||
+18
-3
@@ -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; }
|
||||
/// <summary>
|
||||
/// ProductVersion read from <c>C:\Program Files\RealmJoin\RealmJoin.exe</c>
|
||||
/// (or the x86 fallback). <c>null</c> when the binary is missing, locked, or read
|
||||
/// fails — version is observability-only and never blocks detection.
|
||||
/// </summary>
|
||||
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 ?? "<unknown>"})");
|
||||
try { RealmJoinDetected?.Invoke(this, new RealmJoinDetectedEventArgs(initial, productVersion)); }
|
||||
catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinDetected handler threw", ex); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? "<unknown>"})"),
|
||||
payload: payload);
|
||||
|
||||
var data = new Dictionary<string, string>(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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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<DecisionEffect>();
|
||||
|
||||
@@ -32,6 +32,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
lastDeploymentPhase: null,
|
||||
outcome: null,
|
||||
selfDeployingDeferredCompletion: null,
|
||||
productVersion: null,
|
||||
packages: Array.Empty<RealmJoinPackageFact>());
|
||||
|
||||
public RealmJoinFacts(
|
||||
@@ -40,6 +41,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
SignalFact<int>? lastDeploymentPhase,
|
||||
SignalFact<string>? outcome,
|
||||
SignalFact<bool>? selfDeployingDeferredCompletion,
|
||||
SignalFact<string>? productVersion,
|
||||
IReadOnlyList<RealmJoinPackageFact> packages)
|
||||
{
|
||||
DetectedUtc = detectedUtc;
|
||||
@@ -47,6 +49,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
LastDeploymentPhase = lastDeploymentPhase;
|
||||
Outcome = outcome;
|
||||
SelfDeployingDeferredCompletion = selfDeployingDeferredCompletion;
|
||||
ProductVersion = productVersion;
|
||||
Packages = packages ?? Array.Empty<RealmJoinPackageFact>();
|
||||
}
|
||||
|
||||
@@ -70,6 +73,13 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
/// </summary>
|
||||
public SignalFact<bool>? SelfDeployingDeferredCompletion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>ProductVersion</c> read from <c>C:\Program Files\RealmJoin\RealmJoin.exe</c> at
|
||||
/// detection time. Observability-only — never gates a decision. Null when the binary
|
||||
/// was missing or unreadable.
|
||||
/// </summary>
|
||||
public SignalFact<string>? ProductVersion { get; }
|
||||
|
||||
/// <summary>Tracked per-package install rows (machine + user scope combined).</summary>
|
||||
public IReadOnlyList<RealmJoinPackageFact> Packages { get; }
|
||||
|
||||
@@ -82,6 +92,26 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
lastDeploymentPhase: LastDeploymentPhase,
|
||||
outcome: Outcome,
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
packages: Packages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set-once observability fact carrying the <c>RealmJoin.exe</c> ProductVersion. No-op
|
||||
/// when <paramref name="productVersion"/> is empty or already set. Pure metadata —
|
||||
/// never participates in a gate decision.
|
||||
/// </summary>
|
||||
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<string>(productVersion, sourceSignalOrdinal),
|
||||
packages: Packages);
|
||||
}
|
||||
|
||||
@@ -94,6 +124,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
lastDeploymentPhase: new SignalFact<int>(phase, sourceSignalOrdinal),
|
||||
outcome: new SignalFact<string>(OutcomeResolved, sourceSignalOrdinal),
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
packages: Packages);
|
||||
}
|
||||
|
||||
@@ -106,6 +137,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
lastDeploymentPhase: new SignalFact<int>(phase, sourceSignalOrdinal),
|
||||
outcome: Outcome,
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
packages: Packages);
|
||||
}
|
||||
|
||||
@@ -118,6 +150,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
lastDeploymentPhase: LastDeploymentPhase,
|
||||
outcome: new SignalFact<string>(OutcomeTimeout, sourceSignalOrdinal),
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
packages: Packages);
|
||||
}
|
||||
|
||||
@@ -130,6 +163,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
lastDeploymentPhase: LastDeploymentPhase,
|
||||
outcome: Outcome,
|
||||
selfDeployingDeferredCompletion: new SignalFact<bool>(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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user