Agent Host changes for agents/gather-rule-exitcode-info-update (#151)

This commit is contained in:
Oliver Kieselbach
2026-09-08 14:57:30 +02:00
committed by GitHub
parent 4e73ba8900
commit e33d85c01d
23 changed files with 439 additions and 10 deletions
+5
View File
@@ -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\")."
@@ -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
{
/// <summary>
/// <see cref="GatherRule.EnrichErrorCodes"/> is an opt-in the backend honours at response time:
/// the agent stamps <c>enrichErrorCodes: true</c> into the event data of a rule that set it and
/// nothing at all otherwise (the default payload stays as it was).
/// </summary>
[Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
public sealed class GatherRuleExecutorEnrichErrorCodesTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
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<string, object> { ["exitCode"] = "1603" };
GatherRuleExecutor.StampRuleMarkers(StartupRule(enrichErrorCodes: true), optedIn);
Assert.Equal(true, optedIn[Constants.GatherRuleDataKeys.EnrichErrorCodes]);
var defaultRule = new Dictionary<string, object> { ["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<GatherRule> { 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<GatherRule> { StartupRule(enrichErrorCodes: false) });
Assert.True(_executor.WaitForStartupRules(30));
Assert.True(WaitForEventCount(1));
Assert.False(SingleEvent().Data.ContainsKey(Constants.GatherRuleDataKeys.EnrichErrorCodes));
}
}
}
@@ -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
@@ -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.
/// </summary>
public const string SourceName = "GatherRuleExecutor";
public const string SourceName = Constants.EventSources.GatherRuleExecutor;
/// <summary>
/// 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) =====
/// <summary>
/// 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).
/// </summary>
internal static void StampRuleMarkers(GatherRule rule, Dictionary<string, object> data)
{
if (rule.EnrichErrorCodes)
data[Constants.GatherRuleDataKeys.EnrichErrorCodes] = true;
}
private static bool HasPhaseScope(GatherRule rule)
{
return (rule.ActivePhases != null && rule.ActivePhases.Count > 0)
@@ -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]
@@ -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<IRuleRepository> repo, List<GatherRule> 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);
}
@@ -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()
{
@@ -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.
/// </para>
/// <para>
/// Events emitted by the gather-rule pipeline (<see cref="GatherRuleSource"/>) are only
/// enriched when the rule opted in via <see cref="GatherRule.EnrichErrorCodes"/> — the agent
/// then stamps <c>enrichErrorCodes: true</c> 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.
/// </para>
/// </summary>
public static class ErrorCodeEnricher
{
/// <summary>
/// <see cref="EnrollmentEvent.Source"/> stamped by the agent on every gather-rule event.
/// </summary>
internal const string GatherRuleSource = Constants.EventSources.GatherRuleExecutor;
/// <summary>
/// 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 <c>Data</c>.
@@ -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);
/// <summary>
/// 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.
/// </summary>
private static bool HasEnrichOptIn(Dictionary<string, object> 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<string, object> data, string lowerKey, out string actualKey)
{
foreach (var k in data.Keys)
@@ -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;
}
/// <summary>
@@ -233,6 +233,7 @@ namespace AutopilotMonitor.Functions.Services
["ActivePhasesJson"] = JsonConvert.SerializeObject(rule.ActivePhases ?? new List<string>()),
["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")),
@@ -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 — /);
});
});
@@ -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" */
@@ -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 ' +
@@ -178,6 +178,11 @@ export const GATHER_RULE_SCHEMA: Record<string, unknown> = {
"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\")."
@@ -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<string, unknown>, 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<string, unknown> | undefined): { errorCode?: string; errorText?: string } {
export function keyEventErrorCode(
data: Record<string, unknown> | 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;
@@ -823,6 +823,27 @@ namespace AutopilotMonitor.Shared
public const string Registry = "Registry";
public const string WMI = "WMI";
public const string Network = "Network";
/// <summary>
/// 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 <see cref="GatherRuleDataKeys.EnrichErrorCodes"/>).
/// </summary>
public const string GatherRuleExecutor = "GatherRuleExecutor";
}
/// <summary>
/// Well-known keys the agent injects into gather-rule event data next to the
/// collector's own fields.
/// </summary>
public static class GatherRuleDataKeys
{
/// <summary>
/// <c>true</c> when the rule opted into <see cref="Models.GatherRule.EnrichErrorCodes"/>;
/// absent otherwise. Lets the read-time error-code enricher honour the rule setting
/// without knowing the rule.
/// </summary>
public const string EnrichErrorCodes = "enrichErrorCodes";
}
// -----------------------------------------------------------------------
@@ -165,6 +165,19 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string? EmitMode { get; set; }
/// <summary>
/// Opt-in: let the backend explain error-code fields in this rule's output
/// (<c>exitCode</c>, <c>errorCode</c>, <c>hresult</c>, …) with the Windows/MSI/Intune
/// catalog, i.e. attach the <c>*Info</c> 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 <c>enrichErrorCodes: true</c> into the
/// event data so the read-time enricher can honour it without knowing the rule.
/// </summary>
public bool EnrichErrorCodes { get; set; } = false;
// ===== OUTPUT =====
/// <summary>
@@ -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,
});
});
});
@@ -382,6 +382,13 @@ export function GatherRuleCard({
{rule.outputSeverity.charAt(0).toUpperCase() + rule.outputSeverity.slice(1)}
</span>
</div>
{rule.enrichErrorCodes && (
<div title="exitCode / errorCode / hresult fields in this rule's output are explained with the Windows/MSI/Intune error-code catalog">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-700">
Error codes explained
</span>
</div>
)}
</div>
</div>
</div>
@@ -663,6 +663,26 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
</select>
</div>
</div>
{/* 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. */}
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.enrichErrorCodes}
onChange={(e) => setForm({ ...form, enrichErrorCodes: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span className="text-sm font-medium text-gray-700">Explain error codes with the Windows catalog</span>
</label>
<p className="text-xs text-gray-400 mt-1 ml-6">
Attaches the Windows/MSI/Intune meaning to <code className="px-1 bg-gray-100 rounded">exitCode</code>,{" "}
<code className="px-1 bg-gray-100 rounded">errorCode</code> and <code className="px-1 bg-gray-100 rounded">hresult</code> fields
in this rule&apos;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.
</p>
</div>
</div>
);
}
@@ -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") : [],
@@ -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": {
@@ -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" */