From e33d85c01d6c57bec01f9018233e075caf38d536 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 8 Sep 2026 14:57:30 +0200 Subject: [PATCH] Agent Host changes for agents/gather-rule-exitcode-info-update (#151) --- rules/schema/gather-rule.schema.json | 5 + ...GatherRuleExecutorEnrichErrorCodesTests.cs | 111 ++++++++++++++++++ .../Gather/Collectors/LogParserCollector.cs | 2 + .../Telemetry/Gather/GatherRuleExecutor.cs | 14 ++- .../GatherRuleScopeFieldsTests.cs | 27 +++++ .../GatherRuleUpdatePartialMergeTests.cs | 2 + .../Services/ErrorCodeEnricherTests.cs | 82 +++++++++++++ .../Services/ErrorCodeEnricher.cs | 31 +++++ .../Services/GatherRuleService.cs | 3 +- .../Services/TableStorageService.Rules.cs | 3 + .../__tests__/tools-lookup-error-code.test.ts | 20 ++++ .../src/generated/wire-types.generated.ts | 2 + .../src/rule-authoring-guide.ts | 7 ++ .../src/rule-authoring.generated.ts | 5 + .../src/tools/sessions.ts | 28 ++++- .../AutopilotMonitor.Shared/Constants.cs | 21 ++++ .../Models/Rules/GatherRule.cs | 13 ++ .../__tests__/gatherRuleFormLogic.test.ts | 28 ++++- .../components/GatherRuleCard.tsx | 7 ++ .../components/GatherRuleFormFields.tsx | 20 ++++ .../app/gather-rules/types.ts | 8 +- .../utils/shared-manifests.json | 8 ++ .../utils/wire-types.generated.ts | 2 + 23 files changed, 439 insertions(+), 10 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorEnrichErrorCodesTests.cs diff --git a/rules/schema/gather-rule.schema.json b/rules/schema/gather-rule.schema.json index 6b721cb2..be6d14e3 100644 --- a/rules/schema/gather-rule.schema.json +++ b/rules/schema/gather-rule.schema.json @@ -114,6 +114,11 @@ "description": "Emit behavior: \"always\" (default — emit on every collection) or \"on_change\" (poll on the trigger cadence but emit only when the collected result changes; the first in-scope result always emits).", "enum": ["always", "on_change"] }, + "enrichErrorCodes": { + "type": "boolean", + "description": "Opt-in: let the backend explain error-code fields in this rule's output (exitCode, errorCode, hresult, ...) with the Windows/MSI/Intune error-code catalog, attaching the same *Info siblings built-in events get. Default false because gather-rule codes usually come from third-party logs or commands (HP Image Assistant, Dell Command Update, custom scripts) with their own exit-code numbering, where the catalog meaning would be wrong. Enable only when the parsed source really uses Windows codes (e.g. an msiexec or IME log).", + "default": false + }, "outputEventType": { "type": "string", "description": "EventType for the emitted event (e.g., \"gather_proxy_settings\")." diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorEnrichErrorCodesTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorEnrichErrorCodesTests.cs new file mode 100644 index 00000000..6ef6b199 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorEnrichErrorCodesTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather +{ + /// + /// is an opt-in the backend honours at response time: + /// the agent stamps enrichErrorCodes: true into the event data of a rule that set it and + /// nothing at all otherwise (the default payload stays as it was). + /// + [Collection("SerialThreading")] // startup rules execute on the shared ThreadPool + public sealed class GatherRuleExecutorEnrichErrorCodesTests : IDisposable + { + private readonly TempDirectory _tmp = new TempDirectory(); + private readonly List _events = new List(); + private readonly object _eventsGate = new object(); + private readonly GatherRuleExecutor _executor; + + private const string AbsentPath = + "HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_7e3a1f4b-0000-0000-0000-000000000000"; + + public GatherRuleExecutorEnrichErrorCodesTests() + { + var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info); + _executor = new GatherRuleExecutor( + "sess", "tenant", + evt => { lock (_eventsGate) _events.Add(evt); }, + logger) + { + UnrestrictedMode = true + }; + } + + public void Dispose() + { + _executor.Dispose(); + _tmp.Dispose(); + } + + private EnrollmentEvent SingleEvent() + { + lock (_eventsGate) return Assert.Single(_events); + } + + private bool WaitForEventCount(int count, int timeoutMs = 10000) + { + var sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < timeoutMs) + { + lock (_eventsGate) if (_events.Count >= count) return true; + Thread.Sleep(25); + } + lock (_eventsGate) return _events.Count >= count; + } + + private static GatherRule StartupRule(bool enrichErrorCodes) => new GatherRule + { + RuleId = "GATHER-EEC-001", + Title = "enrich test", + CollectorType = "registry", + Target = AbsentPath, + Trigger = "startup", + OutputEventType = "gather_test", + Enabled = true, + EnrichErrorCodes = enrichErrorCodes, + }; + + [Fact] + public void StampRuleMarkers_AddsMarkerOnlyWhenOptedIn() + { + var optedIn = new Dictionary { ["exitCode"] = "1603" }; + GatherRuleExecutor.StampRuleMarkers(StartupRule(enrichErrorCodes: true), optedIn); + Assert.Equal(true, optedIn[Constants.GatherRuleDataKeys.EnrichErrorCodes]); + + var defaultRule = new Dictionary { ["exitCode"] = "1603" }; + GatherRuleExecutor.StampRuleMarkers(StartupRule(enrichErrorCodes: false), defaultRule); + Assert.False(defaultRule.ContainsKey(Constants.GatherRuleDataKeys.EnrichErrorCodes)); + Assert.Single(defaultRule); + } + + [Fact] + public void ExecuteRule_StampsMarker_WhenRuleOptsIn() + { + _executor.UpdateRules(new List { StartupRule(enrichErrorCodes: true) }); + + Assert.True(_executor.WaitForStartupRules(30)); + Assert.True(WaitForEventCount(1)); + var evt = SingleEvent(); + Assert.Equal(GatherRuleExecutor.SourceName, evt.Source); + Assert.Equal(true, evt.Data[Constants.GatherRuleDataKeys.EnrichErrorCodes]); + } + + [Fact] + public void ExecuteRule_LeavesPayloadUnchanged_ByDefault() + { + _executor.UpdateRules(new List { StartupRule(enrichErrorCodes: false) }); + + Assert.True(_executor.WaitForStartupRules(30)); + Assert.True(WaitForEventCount(1)); + Assert.False(SingleEvent().Data.ContainsKey(Constants.GatherRuleDataKeys.EnrichErrorCodes)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs index a9d8051b..6a5fedb9 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs @@ -278,6 +278,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors data["logFile"] = Path.GetFileName(filePath); data["ruleId"] = rule.RuleId; data["ruleTitle"] = rule.Title; + GatherRuleExecutor.StampRuleMarkers(rule, data); var eventType = !string.IsNullOrEmpty(rule.OutputEventType) ? rule.OutputEventType @@ -347,6 +348,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors data["logFile"] = Path.GetFileName(filePath); data["ruleId"] = rule.RuleId; data["ruleTitle"] = rule.Title; + GatherRuleExecutor.StampRuleMarkers(rule, data); var eventType = !string.IsNullOrEmpty(rule.OutputEventType) ? rule.OutputEventType diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs index 731fae2d..6299fd8c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs @@ -26,7 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather /// emits (executor, context guard events, collectors). The timeline feed uses it to keep /// gather output from re-triggering on_event rules. /// - public const string SourceName = "GatherRuleExecutor"; + public const string SourceName = Constants.EventSources.GatherRuleExecutor; /// /// Upper bound on on_event executions per rule and session. on_event has no natural @@ -496,6 +496,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather result["ruleId"] = rule.RuleId; result["ruleTitle"] = rule.Title; + StampRuleMarkers(rule, result); var eventType = !string.IsNullOrEmpty(rule.OutputEventType) ? rule.OutputEventType : Constants.EventTypes.GatherResult; @@ -537,6 +538,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather // ===== Phase scope (ActivePhases / ActiveFromPhase) ===== + /// + /// Injects the per-rule opt-in markers the backend reads at response time. Called after + /// the on_change hash is taken (the markers are rule config, not collected data) by the + /// executor and by collectors that emit directly (logparser). + /// + internal static void StampRuleMarkers(GatherRule rule, Dictionary data) + { + if (rule.EnrichErrorCodes) + data[Constants.GatherRuleDataKeys.EnrichErrorCodes] = true; + } + private static bool HasPhaseScope(GatherRule rule) { return (rule.ActivePhases != null && rule.ActivePhases.Count > 0) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GatherRuleScopeFieldsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GatherRuleScopeFieldsTests.cs index ff6b3a2d..d7ea483f 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GatherRuleScopeFieldsTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GatherRuleScopeFieldsTests.cs @@ -129,6 +129,29 @@ public class GatherRuleScopeFieldsTests Assert.Null(mapped.ActivePhases); Assert.Null(mapped.ActiveFromPhase); Assert.Null(mapped.EmitMode); + Assert.False(mapped.EnrichErrorCodes); + } + + [Fact] + public async Task Roundtrip_EnrichErrorCodes_SurvivesStoreAndMap() + { + var (service, lastUpserted) = BuildStorageHarness(); + var rule = new GatherRule + { + RuleId = "GATHER-ENRICH-001", + Title = "MSI log parser", + CollectorType = "logparser", + Target = "C:\\Windows\\Temp\\msi.log", + Trigger = "interval", + IntervalSeconds = 60, + OutputEventType = "gather_msi", + EnrichErrorCodes = true, + }; + + Assert.True(await service.StoreGatherRuleAsync(rule, "global")); + var mapped = service.MapToGatherRule(lastUpserted()!); + + Assert.True(mapped.EnrichErrorCodes); } // ── ContentEquivalent ────────────────────────────────────────────────── @@ -175,6 +198,10 @@ public class GatherRuleScopeFieldsTests var emit = BaseRule(); emit.EmitMode = "on_change"; Assert.False(GatherRuleService.ContentEquivalent(baseline, emit)); + + var enrich = BaseRule(); + enrich.EnrichErrorCodes = true; + Assert.False(GatherRuleService.ContentEquivalent(baseline, enrich)); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GatherRuleUpdatePartialMergeTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GatherRuleUpdatePartialMergeTests.cs index d93975ae..a42cf4b7 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GatherRuleUpdatePartialMergeTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GatherRuleUpdatePartialMergeTests.cs @@ -37,6 +37,7 @@ public class GatherRuleUpdatePartialMergeTests CreatedAt = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc), ActiveFromPhase = "AccountSetup", EmitMode = "on_change", + EnrichErrorCodes = true, }; private static (GatherRuleService service, Mock repo, List stored) BuildService( @@ -77,6 +78,7 @@ public class GatherRuleUpdatePartialMergeTests Assert.Equal("Version", written.Parameters["valueName"]); Assert.Equal("AccountSetup", written.ActiveFromPhase); Assert.Equal("on_change", written.EmitMode); + Assert.True(written.EnrichErrorCodes); Assert.Equal(existing.CreatedAt, written.CreatedAt); Assert.Equal("Alice Admin", written.Author); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Services/ErrorCodeEnricherTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Services/ErrorCodeEnricherTests.cs index 3c592409..63db68eb 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Services/ErrorCodeEnricherTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Services/ErrorCodeEnricherTests.cs @@ -160,6 +160,88 @@ public sealed class ErrorCodeEnricherTests Assert.Single(nested); } + [Fact] + public void EnrichEvent_skips_gather_rule_events_by_default() + { + // A logparser gather rule on HP Image Assistant.log captured `exitcode` — HPiA has its own + // exit-code table, so the MSI/Win32 catalog meaning ("ERROR_SUCCESS") must not be attached + // unless the rule opted in (no enrichErrorCodes marker here). + var evt = new EnrollmentEvent + { + EventType = "HPiA-UpdateStatus", + Source = "GatherRuleExecutor", + Message = "Gather: HP Updater Log Analyze", + Data = new() + { + { "exitcode", "0" }, + { "errorCode", "0x80070005" }, + { "enforcementState", "1000" }, + { "ruleId", "hpia-log-collect" }, + }, + }; + + ErrorCodeEnricher.EnrichEvent(evt); + + Assert.DoesNotContain(evt.Data.Keys, k => k.EndsWith("Info")); + Assert.Equal(4, evt.Data.Count); + } + + [Fact] + public void EnrichEvent_matches_gather_rule_source_case_insensitively() + { + var evt = EventWithData(new() { { "exitCode", "1603" } }); + evt.Source = "gatherruleexecutor"; + + ErrorCodeEnricher.EnrichEvent(evt); + + Assert.False(evt.Data.ContainsKey("exitCodeInfo")); + } + + [Theory] + [InlineData(true)] // in-process CLR bool + [InlineData("true")] // string form after DataJson roundtrip + [InlineData("True")] + public void EnrichEvent_enriches_gather_rule_events_that_opted_in(object marker) + { + // A rule that parses an msiexec log set enrichErrorCodes; the agent stamped the marker. + var evt = EventWithData(new() + { + { "exitCode", "1603" }, + { "ruleId", "msi-log" }, + { "enrichErrorCodes", marker }, + }); + evt.Source = "GatherRuleExecutor"; + + ErrorCodeEnricher.EnrichEvent(evt); + + Assert.Equal("ERROR_INSTALL_FAILURE", Info(evt, "exitCodeInfo")["symbol"]); + } + + [Theory] + [InlineData(false)] + [InlineData("false")] + [InlineData("")] + public void EnrichEvent_ignores_gather_rule_marker_that_is_not_true(object marker) + { + var evt = EventWithData(new() { { "exitCode", "1603" }, { "enrichErrorCodes", marker } }); + evt.Source = "GatherRuleExecutor"; + + ErrorCodeEnricher.EnrichEvent(evt); + + Assert.False(evt.Data.ContainsKey("exitCodeInfo")); + } + + [Fact] + public void EnrichEvent_marker_on_non_gather_event_changes_nothing() + { + // Built-in events are always enriched; the marker is only consulted for gather events. + var evt = EventWithData(new() { { "exitCode", "1603" }, { "enrichErrorCodes", false } }); + + ErrorCodeEnricher.EnrichEvent(evt); + + Assert.True(evt.Data.ContainsKey("exitCodeInfo")); + } + [Fact] public void EnrichEvent_handles_null_data_dictionary() { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/ErrorCodeEnricher.cs b/src/Backend/AutopilotMonitor.Functions/Services/ErrorCodeEnricher.cs index 94e64b3b..afe3958f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/ErrorCodeEnricher.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/ErrorCodeEnricher.cs @@ -1,6 +1,7 @@ #nullable enable using System; using System.Collections.Generic; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Services; @@ -18,9 +19,23 @@ namespace AutopilotMonitor.Functions.Services /// Idempotent: when a sibling info key is already present the entry is skipped. /// Read-only on the storage layer — never writes back to the table. /// + /// + /// Events emitted by the gather-rule pipeline () are only + /// enriched when the rule opted in via — the agent + /// then stamps enrichErrorCodes: true into the data. Without it, their payload keys are + /// author-chosen and the codes come from arbitrary third-party logs or commands (HP Image + /// Assistant, Dell Command Update, custom scripts …) with their own exit-code numbering; + /// resolving those against the Windows/MSI/Intune catalog would attach a confident but + /// wrong meaning. + /// /// public static class ErrorCodeEnricher { + /// + /// stamped by the agent on every gather-rule event. + /// + internal const string GatherRuleSource = Constants.EventSources.GatherRuleExecutor; + /// /// Keys (lower-cased) whose value the enricher looks up. Match is case-insensitive. /// Each is paired with the sibling info-key written next to it in Data. @@ -42,6 +57,7 @@ namespace AutopilotMonitor.Functions.Services public static void EnrichEvent(EnrollmentEvent evt) { if (evt?.Data == null || evt.Data.Count == 0) return; + if (IsGatherRuleEvent(evt) && !HasEnrichOptIn(evt.Data)) return; foreach (var pair in _codeKeyPairs) { @@ -87,6 +103,21 @@ namespace AutopilotMonitor.Functions.Services foreach (var e in events) EnrichEvent(e); } + private static bool IsGatherRuleEvent(EnrollmentEvent evt) => + string.Equals(evt.Source, GatherRuleSource, StringComparison.OrdinalIgnoreCase); + + /// + /// The agent-stamped opt-in marker. Data may hold a CLR bool (in-process) or a + /// deserialized JSON token / string (from DataJson), so the check goes via ToString. + /// + private static bool HasEnrichOptIn(Dictionary data) + { + if (!TryFindKey(data, Constants.GatherRuleDataKeys.EnrichErrorCodes, out var key)) return false; + var raw = data[key]; + if (raw is bool b) return b; + return string.Equals(raw?.ToString(), "true", StringComparison.OrdinalIgnoreCase); + } + private static bool TryFindKey(Dictionary data, string lowerKey, out string actualKey) { foreach (var k in data.Keys) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs index ca5d8187..24aac405 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs @@ -505,7 +505,8 @@ namespace AutopilotMonitor.Functions.Services && a.TriggerPhase == b.TriggerPhase && ActivePhasesEquivalent(a.ActivePhases, b.ActivePhases) && a.ActiveFromPhase == b.ActiveFromPhase - && a.EmitMode == b.EmitMode; + && a.EmitMode == b.EmitMode + && a.EnrichErrorCodes == b.EnrichErrorCodes; } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs index 0822ff73..c1cc1ff5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs @@ -233,6 +233,7 @@ namespace AutopilotMonitor.Functions.Services ["ActivePhasesJson"] = JsonConvert.SerializeObject(rule.ActivePhases ?? new List()), ["ActiveFromPhase"] = rule.ActiveFromPhase ?? string.Empty, ["EmitMode"] = rule.EmitMode ?? string.Empty, + ["EnrichErrorCodes"] = rule.EnrichErrorCodes, ["OutputEventType"] = rule.OutputEventType ?? string.Empty, ["OutputSeverity"] = rule.OutputSeverity ?? "Info", ["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]), @@ -343,6 +344,8 @@ namespace AutopilotMonitor.Functions.Services ActivePhases = MapActivePhases(entity.GetString("ActivePhasesJson")), ActiveFromPhase = string.IsNullOrEmpty(entity.GetString("ActiveFromPhase")) ? null : entity.GetString("ActiveFromPhase"), EmitMode = string.IsNullOrEmpty(entity.GetString("EmitMode")) ? null : entity.GetString("EmitMode"), + // Absent column (pre-existing rows) → false: the opt-in never turns itself on. + EnrichErrorCodes = entity.GetBoolean("EnrichErrorCodes") ?? false, OutputEventType = entity.GetString("OutputEventType") ?? string.Empty, OutputSeverity = entity.GetString("OutputSeverity") ?? "Info", Tags = DeserializeJsonArray(entity.GetString("TagsJson")), diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-lookup-error-code.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-lookup-error-code.test.ts index 4653242d..8027b056 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-lookup-error-code.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-lookup-error-code.test.ts @@ -145,4 +145,24 @@ describe('keyEventErrorCode (get_session_summary)', () => { it('surfaces an unknown code without text', () => { expect(keyEventErrorCode({ errorCode: '0xDEADBEEF' })).toEqual({ errorCode: '0xDEADBEEF' }); }); + + it('never explains gather-rule codes by default — they live in the vendor\'s own numbering (HPiA, DCU, …)', () => { + // exitCode 1603 is a well-known MSI code, but from a gather rule it means whatever the parsed + // log says; neither the local catalog nor a stale backend sibling may attach text. + expect(keyEventErrorCode({ exitCode: '1603' }, 'GatherRuleExecutor')).toEqual({ errorCode: '1603' }); + expect(keyEventErrorCode({ + exitCode: '1603', + exitCodeInfo: { description: 'A fatal error occurred during installation', symbol: 'ERROR_INSTALL_FAILURE' }, + }, 'gatherruleexecutor')).toEqual({ errorCode: '1603' }); + expect(keyEventErrorCode({ exitCode: '1603', enrichErrorCodes: false }, 'GatherRuleExecutor')).toEqual({ errorCode: '1603' }); + expect(keyEventErrorCode({ exitCode: '1603' }, 'ImeLogMonitor').errorText).toMatch(/^ERROR_INSTALL_FAILURE — /); + }); + + it('explains gather-rule codes when the rule opted in (agent-stamped enrichErrorCodes marker)', () => { + expect(keyEventErrorCode({ exitCode: '1603', enrichErrorCodes: true }, 'GatherRuleExecutor').errorText) + .toMatch(/^ERROR_INSTALL_FAILURE — /); + // Marker as a string after a DataJson roundtrip. + expect(keyEventErrorCode({ exitCode: '1603', enrichErrorCodes: 'true' }, 'GatherRuleExecutor').errorText) + .toMatch(/^ERROR_INSTALL_FAILURE — /); + }); }); diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index c4ea7e5f..c43083b3 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -1859,6 +1859,8 @@ export interface GatherRule { activeFromPhase?: string | null; /** Emit behavior for collected results: null / "always" = emit on every collection (legacy behavior); "on_change" = poll on the trigger cadence but emit only when the collected result differs from the last emitted one. The first in-scope result always emits; the suppressed poll count is carried on the next emitted event (suppressedPolls / suppressedSinceUtc in the event data). */ emitMode?: string | null; + /** Opt-in: let the backend explain error-code fields in this rule's output (exitCode, errorCode, hresult, …) with the Windows/MSI/Intune catalog, i.e. attach the *Info siblings the built-in events get. Default false — gather-rule payload keys are author-chosen and the codes usually come from third-party logs or commands (HP Image Assistant, Dell Command Update, custom scripts) with their own exit-code numbering, where the catalog meaning would be confidently wrong. Set to true only when the parsed source really uses Windows codes (e.g. an msiexec or IME log). The agent stamps enrichErrorCodes: true into the event data so the read-time enricher can honour it without knowing the rule. */ + enrichErrorCodes: boolean; /** EventType for the emitted event (e.g., "gather_proxy_settings") */ outputEventType: string; /** Severity for the emitted event Default: "Info" */ diff --git a/src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts b/src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts index d0893562..c99c71e9 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts @@ -107,6 +107,13 @@ export const RULE_AUTHORING_GUIDE = { 'Optional activePhases (array) or activeFromPhase restrict when the rule may run at all; ' + 'emitMode "on_change" emits only when the collected payload differs from the previous run ' + '(default "always").', + errorCodes: + 'Gather-rule output is NOT run through the Windows/MSI/Intune error-code catalog by default: ' + + 'a captured exitCode/errorCode/hresult from a third-party log (HP Image Assistant, Dell ' + + 'Command Update, custom scripts) uses the vendor\'s own numbering, and a catalog meaning would ' + + 'be confidently wrong. Set enrichErrorCodes: true only when the parsed source really uses ' + + 'Windows codes (e.g. an msiexec or IME log); the backend then attaches the *Info siblings ' + + '(exitCodeInfo, errorCodeInfo, ...) exactly like on built-in events.', output: 'outputEventType names the emitted event; use the gather_ prefix convention (e.g. ' + '"gather_pending_reboot") so analyze rules and humans can tell collected events from ' + diff --git a/src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts index 1282cee3..c0eb3b05 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts @@ -178,6 +178,11 @@ export const GATHER_RULE_SCHEMA: Record = { "on_change" ] }, + "enrichErrorCodes": { + "type": "boolean", + "description": "Opt-in: let the backend explain error-code fields in this rule's output (exitCode, errorCode, hresult, ...) with the Windows/MSI/Intune error-code catalog, attaching the same *Info siblings built-in events get. Default false because gather-rule codes usually come from third-party logs or commands (HP Image Assistant, Dell Command Update, custom scripts) with their own exit-code numbering, where the catalog meaning would be wrong. Enable only when the parsed source really uses Windows codes (e.g. an msiexec or IME log).", + "default": false + }, "outputEventType": { "type": "string", "description": "EventType for the emitted event (e.g., \"gather_proxy_settings\")." diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index ba24434d..08991ecf 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -634,7 +634,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: phase: phaseName(e.phase, s.enrollmentType), message: e.message, source: e.source, - ...keyEventErrorCode(e.data), + ...keyEventErrorCode(e.data, e.source), })); let analysis = null; @@ -906,18 +906,40 @@ const ERROR_CODE_KEYS: ReadonlyArray<[code: string, info: string]> = [ ['hresultFromWin32', 'hresultFromWin32Info'], ]; +/** + * `source` the agent stamps on gather-rule output. Those payload keys are author-chosen and the + * codes come from third-party logs/commands (HP Image Assistant, Dell Command Update, custom + * scripts) with their own numbering, so neither the backend enricher nor the local catalog + * fallback may explain them unless the rule opted in via `enrichErrorCodes` (the agent then + * stamps `enrichErrorCodes: true` into the data). The raw code always travels. + */ +const GATHER_RULE_SOURCE = 'GatherRuleExecutor'; +const ENRICH_OPT_IN_KEY = 'enrichErrorCodes'; + +function isOptedOutGatherEvent(data: Record, source: string | null | undefined): boolean { + if (typeof source !== 'string' || source.toLowerCase() !== GATHER_RULE_SOURCE.toLowerCase()) return false; + const marker = data[ENRICH_OPT_IN_KEY]; + return !(marker === true || (typeof marker === 'string' && marker.toLowerCase() === 'true')); +} + /** * `errorCode` + `errorText` for a key event, only when the payload carries a code. The text * comes from the backend-enriched `*Info` sibling (symbol + catalog meaning); an older * response without the sibling falls back to the server's own catalog. A code the catalog - * does not know is still surfaced as errorCode, without errorText. + * does not know is still surfaced as errorCode, without errorText. Gather-rule events + * (`source === GatherRuleExecutor`) get catalog text only when their rule opted in. */ -export function keyEventErrorCode(data: Record | undefined): { errorCode?: string; errorText?: string } { +export function keyEventErrorCode( + data: Record | undefined, + source?: string | null, +): { errorCode?: string; errorText?: string } { if (!data) return {}; + const suppressCatalogText = isOptedOutGatherEvent(data, source); for (const [codeKey, infoKey] of ERROR_CODE_KEYS) { const raw = data[codeKey]; if (raw === undefined || raw === null || raw === '' || raw === 0 || raw === '0') continue; const errorCode = String(raw); + if (suppressCatalogText) return { errorCode }; const info = data[infoKey] as { description?: unknown; symbol?: unknown } | undefined; let symbol: string | undefined; let description: string | undefined; diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 73d1ef6c..c8290aee 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -823,6 +823,27 @@ namespace AutopilotMonitor.Shared public const string Registry = "Registry"; public const string WMI = "WMI"; public const string Network = "Network"; + + /// + /// Stamped on every event the agent's gather-rule pipeline emits (executor, guard + /// events, collectors). Backend and MCP key off it to treat the payload as + /// author-defined (no error-code enrichment unless ). + /// + public const string GatherRuleExecutor = "GatherRuleExecutor"; + } + + /// + /// Well-known keys the agent injects into gather-rule event data next to the + /// collector's own fields. + /// + public static class GatherRuleDataKeys + { + /// + /// true when the rule opted into ; + /// absent otherwise. Lets the read-time error-code enricher honour the rule setting + /// without knowing the rule. + /// + public const string EnrichErrorCodes = "enrichErrorCodes"; } // ----------------------------------------------------------------------- diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs b/src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs index 142fc474..7f587df8 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs @@ -165,6 +165,19 @@ namespace AutopilotMonitor.Shared.Models /// public string? EmitMode { get; set; } + /// + /// Opt-in: let the backend explain error-code fields in this rule's output + /// (exitCode, errorCode, hresult, …) with the Windows/MSI/Intune + /// catalog, i.e. attach the *Info siblings the built-in events get. + /// Default false — gather-rule payload keys are author-chosen and the codes usually come + /// from third-party logs or commands (HP Image Assistant, Dell Command Update, custom + /// scripts) with their own exit-code numbering, where the catalog meaning would be + /// confidently wrong. Set to true only when the parsed source really uses Windows codes + /// (e.g. an msiexec or IME log). The agent stamps enrichErrorCodes: true into the + /// event data so the read-time enricher can honour it without knowing the rule. + /// + public bool EnrichErrorCodes { get; set; } = false; + // ===== OUTPUT ===== /// diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/__tests__/gatherRuleFormLogic.test.ts b/src/Web/autopilot-monitor-web/app/gather-rules/__tests__/gatherRuleFormLogic.test.ts index 5590cdb4..0d0edbb6 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/__tests__/gatherRuleFormLogic.test.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/__tests__/gatherRuleFormLogic.test.ts @@ -84,11 +84,11 @@ describe("buildScopeFields", () => { it("sends the selected scope and emit mode for a repeating trigger", () => { expect(buildScopeFields(form({ trigger: "interval", scopeMode: "from", activeFromPhase: "AccountSetup", emitMode: "on_change", - }))).toEqual({ activePhases: null, activeFromPhase: "AccountSetup", emitMode: "on_change" }); + }))).toEqual({ activePhases: null, activeFromPhase: "AccountSetup", emitMode: "on_change", enrichErrorCodes: false }); expect(buildScopeFields(form({ trigger: "interval", scopeMode: "during", activePhases: ["DeviceSetup", "AppsDevice"], emitMode: "always", - }))).toEqual({ activePhases: ["DeviceSetup", "AppsDevice"], activeFromPhase: null, emitMode: "always" }); + }))).toEqual({ activePhases: ["DeviceSetup", "AppsDevice"], activeFromPhase: null, emitMode: "always", enrichErrorCodes: false }); }); it("drops hidden-control state instead of leaking it into the payload", () => { @@ -103,14 +103,23 @@ describe("buildScopeFields", () => { }); expect(buildScopeFields(switched)).toEqual({ - activePhases: null, activeFromPhase: null, emitMode: null, + activePhases: null, activeFromPhase: null, emitMode: null, enrichErrorCodes: false, }); }); it("keeps a deferring scope on a startup rule but drops its emit mode", () => { expect(buildScopeFields(form({ trigger: "startup", scopeMode: "from", activeFromPhase: "AccountSetup", emitMode: "on_change", - }))).toEqual({ activePhases: null, activeFromPhase: "AccountSetup", emitMode: null }); + }))).toEqual({ activePhases: null, activeFromPhase: "AccountSetup", emitMode: null, enrichErrorCodes: false }); + }); + + it("sends the error-code opt-in for every trigger, and only as a strict boolean", () => { + // Enrichment is about the payload, not the cadence — no trigger hides it. + expect(buildScopeFields(form({ trigger: "startup", enrichErrorCodes: true })).enrichErrorCodes).toBe(true); + expect(buildScopeFields(form({ trigger: "phase_change", triggerPhase: "AccountSetup", enrichErrorCodes: true })).enrichErrorCodes).toBe(true); + expect(buildScopeFields(form({ trigger: "interval", enrichErrorCodes: true })).enrichErrorCodes).toBe(true); + // A JSON-mode paste can carry anything; the backend gets a real boolean. + expect(buildScopeFields(form({ enrichErrorCodes: "true" as unknown as boolean })).enrichErrorCodes).toBe(false); }); it("treats an unfilled scope mode as unrestricted", () => { @@ -130,6 +139,12 @@ describe("withDerivedScopeMode", () => { expect(withDerivedScopeMode(form({ emitMode: "on_change" })).emitMode).toBe("on_change"); }); + it("coerces the error-code opt-in to a strict boolean", () => { + expect(withDerivedScopeMode(form({ enrichErrorCodes: true })).enrichErrorCodes).toBe(true); + expect(withDerivedScopeMode({ ...EMPTY_FORM, enrichErrorCodes: undefined } as unknown as NewRuleForm).enrichErrorCodes).toBe(false); + expect(withDerivedScopeMode({ ...EMPTY_FORM, enrichErrorCodes: "yes" } as unknown as NewRuleForm).enrichErrorCodes).toBe(false); + }); + it("survives non-array activePhases from hand-written JSON", () => { const parsed = { ...EMPTY_FORM, activePhases: undefined } as unknown as NewRuleForm; expect(withDerivedScopeMode(parsed).activePhases).toEqual([]); @@ -166,6 +181,8 @@ describe("gatherRuleToForm", () => { // A rule without emitMode behaves "always" — the import must not upgrade it to // the empty-form on_change default. expect(form.emitMode).toBe("always"); + // A rule without enrichErrorCodes (older export) stays opted out. + expect(form.enrichErrorCodes).toBe(false); }); it("prefers the parameters object over stale flat fields on an edit merge", () => { @@ -213,6 +230,7 @@ describe("gatherRuleToForm", () => { intervalSeconds: 300, activePhases: ["DeviceSetup", "AppsDevice"], emitMode: "on_change", + enrichErrorCodes: true, outputEventType: "battery_status", outputSeverity: "info", tags: ["power"], @@ -223,10 +241,12 @@ describe("gatherRuleToForm", () => { expect(form.activePhases).toEqual(["DeviceSetup", "AppsDevice"]); expect(form.intervalSeconds).toBe(300); expect(form.emitMode).toBe("on_change"); + expect(form.enrichErrorCodes).toBe(true); expect(buildScopeFields(form)).toEqual({ activePhases: ["DeviceSetup", "AppsDevice"], activeFromPhase: null, emitMode: "on_change", + enrichErrorCodes: true, }); }); }); diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx index ffcb84a7..923519b7 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx +++ b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx @@ -382,6 +382,13 @@ export function GatherRuleCard({ {rule.outputSeverity.charAt(0).toUpperCase() + rule.outputSeverity.slice(1)} + {rule.enrichErrorCodes && ( +
+ + Error codes explained + +
+ )} diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx index acbc75ca..74f44c01 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx +++ b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx @@ -663,6 +663,26 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo + + {/* Output: error-code catalog opt-in. Off by default — gather-rule codes usually follow a + vendor's own numbering (HPiA, DCU, custom scripts), where the Windows/MSI meaning is wrong. */} +
+ +

+ Attaches the Windows/MSI/Intune meaning to exitCode,{" "} + errorCode and hresult fields + in this rule's output, like on built-in events. Leave off when the source uses its own exit codes + (HP Image Assistant, Dell Command Update, custom scripts) — the catalog would explain them wrongly. +

+
); } diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/types.ts b/src/Web/autopilot-monitor-web/app/gather-rules/types.ts index a9f89416..33953ac9 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/types.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/types.ts @@ -20,6 +20,7 @@ export interface GatherRule { activePhases?: string[] | null; activeFromPhase?: string | null; emitMode?: string | null; + enrichErrorCodes?: boolean; outputEventType: string; outputSeverity: string; tags: string[]; @@ -57,6 +58,7 @@ export interface NewRuleForm { activePhases: string[]; activeFromPhase: string; emitMode: string; + enrichErrorCodes: boolean; outputEventType: string; outputSeverity: string; tags: string[]; @@ -172,6 +174,7 @@ export const EMPTY_FORM: NewRuleForm = { activeFromPhase: "", // New rules default to on_change (anti-spam); existing rules load as "always" in startEditing. emitMode: "on_change", + enrichErrorCodes: false, outputEventType: "", outputSeverity: "info", tags: [], @@ -253,6 +256,7 @@ export function buildScopeFields(form: NewRuleForm) { activePhases: scoped && form.scopeMode === "during" && form.activePhases.length > 0 ? form.activePhases : null, activeFromPhase: scoped && form.scopeMode === "from" && form.activeFromPhase ? form.activeFromPhase : null, emitMode: supportsEmitMode(form) ? (form.emitMode || null) : null, + enrichErrorCodes: form.enrichErrorCodes === true, }; } @@ -269,7 +273,8 @@ export function withDerivedScopeMode(form: NewRuleForm): NewRuleForm { const scopeMode: NewRuleForm["scopeMode"] = activePhases.length > 0 ? "during" : activeFromPhase ? "from" : "always"; const emitMode = form.emitMode === "on_change" ? "on_change" : "always"; - return { ...form, activePhases, activeFromPhase, scopeMode, emitMode }; + const enrichErrorCodes = form.enrichErrorCodes === true; + return { ...form, activePhases, activeFromPhase, scopeMode, emitMode, enrichErrorCodes }; } /** @@ -322,6 +327,7 @@ export function gatherRuleToForm(input: PastedGatherJson): NewRuleForm { // A rule without the field behaves "always"; only an explicit on_change survives — // the on_change default is for rules started from the empty form, not for imports. emitMode: input.emitMode ?? "always", + enrichErrorCodes: input.enrichErrorCodes === true, outputEventType: input.outputEventType ?? "", outputSeverity: input.outputSeverity ?? EMPTY_FORM.outputSeverity, tags: Array.isArray(input.tags) ? input.tags.filter((t): t is string => typeof t === "string") : [], diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 6f1a38ce..b5dfe944 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -8896,6 +8896,14 @@ "optional": true, "doc": "Emit behavior for collected results: null / \"always\" = emit on every collection (legacy behavior); \"on_change\" = poll on the trigger cadence but emit only when the collected result differs from the last emitted one. The first in-scope result always emits; the suppressed poll count is carried on the next emitted event (suppressedPolls / suppressedSinceUtc in the event data)." }, + { + "name": "enrichErrorCodes", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "Opt-in: let the backend explain error-code fields in this rule's output (exitCode, errorCode, hresult, …) with the Windows/MSI/Intune catalog, i.e. attach the *Info siblings the built-in events get. Default false — gather-rule payload keys are author-chosen and the codes usually come from third-party logs or commands (HP Image Assistant, Dell Command Update, custom scripts) with their own exit-code numbering, where the catalog meaning would be confidently wrong. Set to true only when the parsed source really uses Windows codes (e.g. an msiexec or IME log). The agent stamps enrichErrorCodes: true into the event data so the read-time enricher can honour it without knowing the rule." + }, { "name": "outputEventType", "type": { diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index a12ac82c..1104b956 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -1859,6 +1859,8 @@ export interface GatherRule { activeFromPhase?: string | null; /** Emit behavior for collected results: null / "always" = emit on every collection (legacy behavior); "on_change" = poll on the trigger cadence but emit only when the collected result differs from the last emitted one. The first in-scope result always emits; the suppressed poll count is carried on the next emitted event (suppressedPolls / suppressedSinceUtc in the event data). */ emitMode?: string | null; + /** Opt-in: let the backend explain error-code fields in this rule's output (exitCode, errorCode, hresult, …) with the Windows/MSI/Intune catalog, i.e. attach the *Info siblings the built-in events get. Default false — gather-rule payload keys are author-chosen and the codes usually come from third-party logs or commands (HP Image Assistant, Dell Command Update, custom scripts) with their own exit-code numbering, where the catalog meaning would be confidently wrong. Set to true only when the parsed source really uses Windows codes (e.g. an msiexec or IME log). The agent stamps enrichErrorCodes: true into the event data so the read-time enricher can honour it without knowing the rule. */ + enrichErrorCodes: boolean; /** EventType for the emitted event (e.g., "gather_proxy_settings") */ outputEventType: string; /** Severity for the emitted event Default: "Info" */