From cd29cb86c1690155a93ebefb0a1fad5dd5e70bb1 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 15 Jun 2026 21:34:08 +0200 Subject: [PATCH] feat(agent-v2): detect pre-installed OEM/consumer Office, kill false office_install_failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OfficeInstallDetector fired a false office_install_failed on devices with pre-installed OEM/consumer inbox Office (e.g. O365HomePremRetail + OneNoteFreeRetail) running a background C2R CLIENTUPDATE/maintenance scenario: the already-resident Office was mis-read as a failed Microsoft 365 Apps install, with a benign small-integer scenario value ("2") treated as a failure code. Field telemetry (202 office_install_started events) showed 200/202 real installs start with streamingFinished=false (a live download) and only 2 already-resident — so the clean discriminator is "already on disk", not the scenario name. A "require INSTALL scenario to start" gate was rejected: 47% of real starts fire via the early DO-CDN job before C2R writes its registry. Changes: - New office_preinstalled_detected (Info): emitted when the core Office binaries are already on disk at the first signal AND the product set is consumer/OEM inbox (OfficeProductClassifier). Anchored on the filesystem, NOT the flaky StreamingFinished value (absent on some machines). Enterprise Office already on disk keeps the started->completed path (CSP/Win32-wrapper case). - Does NOT latch terminal: an enrollment often uninstalls the inbox Office and installs a fresh one, so the detector stays armed (emit-once guard + StatePreinstalled persisted for cross-reboot dedup, ResumePreinstalled set before the process watcher's startup probe). - Error attribution scoped to the INSTALL scenario only (+ errorSource); a CLIENTUPDATE error never fails an install. - Registry idle-start gated on InstallScenarioPresent (not any scenario). - IsNonZeroNumericCode rejects implausibly small positive decimals (<1000). - DO bytes are seeded only once a real lifecycle opens, so a preinstalled/ CLIENTUPDATE download cannot leak into a later install's doSummary. - Web InstallProgress renders a sky "Pre-installed" info row; MCP + web event catalogs updated. V2.Core 1454/1454, web tsc clean, MCP 240 passed. Inert until redeploy. Co-Authored-By: Claude Opus 4.8 --- .../Office/OfficeInstallDetectorTests.cs | 204 +++++++++++++++++- .../OfficeInstallStatePersistenceTests.cs | 1 + .../Telemetry/Office/OfficeInstallDetector.cs | 148 ++++++++++++- .../Office/OfficeInstallStatePersistence.cs | 10 +- .../Office/OfficeProductClassifier.cs | 48 +++++ .../Orchestration/DefaultComponentFactory.cs | 9 +- .../Hosts/OfficeInstallDetectorHost.cs | 25 ++- .../src/resource-catalog.ts | 1 + .../AutopilotMonitor.Shared/Constants.cs | 4 + .../app/gather-rules/eventTypes.ts | 2 + .../app/sessions/[sessionId]/page.tsx | 2 +- .../components/InstallProgress.tsx | 49 ++++- 12 files changed, 473 insertions(+), 30 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeProductClassifier.cs diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallDetectorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallDetectorTests.cs index 85fc2ca0..62b35ff8 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallDetectorTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallDetectorTests.cs @@ -53,7 +53,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office public List OfficeEvents() => Sink.Posted .Where(p => p.Payload != null && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) - && et != null && et.StartsWith("office_install_", StringComparison.Ordinal)) + && et != null && et.StartsWith("office_", StringComparison.Ordinal)) .ToList(); public void Dispose() => _tmp.Dispose(); @@ -69,6 +69,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office StreamingFinished = false, ActiveScenarioPresent = true, ActiveScenarioName = "INSTALL", + InstallScenarioPresent = true, + InstallScenarioName = "INSTALL", OfficeC2RClientRunning = true, }; s.Products.Add("O365ProPlusRetail"); @@ -92,6 +94,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office return s; } + // An Office that is already fully resident on disk at the first signal: OEM/consumer inbox + // Office (O365HomePremRetail + OneNoteFreeRetail) running a background CLIENTUPDATE — the + // false-positive shape from field session fa526757. + private static OfficeC2RSnapshot PreinstalledConsumer(string scenario = "CLIENTUPDATE") + { + var s = new OfficeC2RSnapshot + { + ConfigurationKeyPresent = true, + Channel = "Current", + Platform = "x64", + StreamingFinished = true, + ActiveScenarioPresent = true, + ActiveScenarioName = scenario, + OfficeC2RClientRunning = true, + InstallationPath = @"C:\Program Files\Microsoft Office", + VersionToReport = "16.0.16327.20264", + }; + s.Products.Add("O365HomePremRetail"); + s.Products.Add("OneNoteFreeRetail"); + return s; + } + private static string EventType(FakeSignalIngressSink.PostedSignal p) => p.Payload![SignalPayloadKeys.EventType]; private static string Severity(FakeSignalIngressSink.PostedSignal p) => p.Payload![SignalPayloadKeys.Severity]; private static Dictionary Data(FakeSignalIngressSink.PostedSignal p) @@ -382,12 +406,188 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office Assert.Equal("0x80070005", Data(failed)["errorCode"]); } + // ---------------------------------------------------------------- preinstalled (already-resident) + + [Fact] + public void Resident_consumer_office_emits_preinstalled_not_started_or_failed() + { + // OEM/consumer inbox Office already on disk at the first signal, running a background + // CLIENTUPDATE — surface an informational office_preinstalled_detected, never a + // started/failed pair (field session fa526757 / the CLIENTUPDATE errorCode "2" false alarm). + using var rig = new Rig(PreinstalledConsumer()); + rig.Sut.CoreBinariesProbe = _ => true; // core Office binaries already present on disk + + rig.Sut.OnWorkerStarted(); + + var ev = Assert.Single(rig.OfficeEvents()); + Assert.Equal(Constants.EventTypes.OfficePreinstalledDetected, EventType(ev)); + Assert.Equal("Info", Severity(ev)); + var data = Data(ev); + Assert.Equal("office_already_resident", data["reason"]); + Assert.Equal("CLIENTUPDATE", data["scenario"]); + Assert.Contains("O365HomePremRetail", (List)data["products"]); + } + + [Fact] + public void Resident_consumer_office_with_install_scenario_still_preinstalled() + { + // The real field false positive had scenario=INSTALL yet Office was already resident — the + // filesystem proof (not the scenario name) is what classifies it as preinstalled. + using var rig = new Rig(PreinstalledConsumer(scenario: "INSTALL")); + rig.Sut.CoreBinariesProbe = _ => true; + + rig.Sut.OnWorkerStarted(); + + Assert.Equal(Constants.EventTypes.OfficePreinstalledDetected, EventType(Assert.Single(rig.OfficeEvents()))); + } + + [Fact] + public void Resident_enterprise_office_still_starts_not_preinstalled() + { + // Enterprise Office already on disk (Office CSP / Win32-wrapper re-run, field session + // a7525e97) is NOT preinstalled inbox Office — keep the started→completed path. + var snap = ActiveStreaming(); // O365ProPlusRetail + snap.InstallationPath = @"C:\Program Files\Microsoft Office"; + using var rig = new Rig(snap); + rig.Sut.CoreBinariesProbe = _ => true; + + rig.Sut.OnWorkerStarted(); + + Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(Assert.Single(rig.OfficeEvents()))); + } + + [Fact] + public void Fresh_install_without_binaries_on_disk_starts_normally() + { + // The common case (200/202 field starts): early DO trigger, nothing on disk yet → started. + using var rig = new Rig(PreinstalledConsumer()); + rig.Sut.CoreBinariesProbe = _ => false; // no binaries on disk yet → a genuine fresh install + + rig.Sut.OnWorkerStarted(); + + Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(Assert.Single(rig.OfficeEvents()))); + } + + [Fact] + public void Preinstalled_persists_nonterminal_state_so_next_run_rearms() + { + // Persisted for emit-once dedup, but NOT terminal — the factory must still arm the detector + // next run (an enrollment often uninstalls the inbox Office and installs a fresh one). + using var rig = new Rig(PreinstalledConsumer(), withPersistence: true); + rig.Sut.CoreBinariesProbe = _ => true; + + rig.Sut.OnWorkerStarted(); + + var state = rig.Persistence!.Load(); + Assert.Equal(OfficeInstallStateData.StatePreinstalled, state!.State); + Assert.False(state.IsTerminal); + } + + [Fact] + public void Preinstalled_emits_once_then_a_later_fresh_install_still_starts() + { + // Resident inbox Office reports preinstalled (no lifecycle). Then the enrollment lays down a + // fresh enterprise Microsoft 365 Apps (real download, no binaries on disk yet) — that MUST + // still open a normal started lifecycle (the re-arm requirement). + using var rig = new Rig(PreinstalledConsumer()); + rig.Sut.CoreBinariesProbe = _ => true; + rig.Sut.OnWorkerStarted(); // → office_preinstalled_detected, stays Idle/armed + + // A second resident-consumer signal must NOT emit a duplicate preinstalled. + rig.Sut.OnWorkerStarted(); + + var afterFirst = rig.OfficeEvents(); + Assert.Single(afterFirst); + Assert.Equal(Constants.EventTypes.OfficePreinstalledDetected, EventType(afterFirst[0])); + + // Now a fresh enterprise install begins (binaries not yet on disk → real download). + rig.Current = ActiveStreaming(); // O365ProPlusRetail, streaming + rig.Sut.CoreBinariesProbe = _ => false; + rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 2, FileSize = 1000, TotalBytesDownloaded = 100 }); + + var events = rig.OfficeEvents(); + Assert.Equal(2, events.Count); + Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(events[1])); + } + + [Fact] + public void Preinstalled_do_bytes_do_not_leak_into_a_later_real_install_summary() + { + // A CLIENTUPDATE DO download (large bytes) drives the preinstalled short-circuit — those + // bytes must NOT seed _peakDo, or a later real install's doSummary would report them. + using var rig = new Rig(PreinstalledConsumer()); + rig.Sut.CoreBinariesProbe = _ => true; + rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 3, FileSize = 5000, TotalBytesDownloaded = 4000 }); + Assert.Equal(Constants.EventTypes.OfficePreinstalledDetected, EventType(Assert.Single(rig.OfficeEvents()))); + + // Now a real enterprise install begins (smaller download) and completes. + rig.Current = ActiveStreaming(); + rig.Sut.CoreBinariesProbe = _ => false; + rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 1, FileSize = 1000, TotalBytesDownloaded = 600, BytesFromHttp = 600 }); + rig.Current = CompletedIdle(); + rig.Sut.CoreBinariesProbe = _ => true; + Assert.Equal(OfficeInstallDetector.CompletionOutcome.Completed, rig.Sut.TryFinalizeCompletion()); + + var completed = rig.OfficeEvents().Last(); + Assert.Equal(Constants.EventTypes.OfficeInstallCompleted, EventType(completed)); + var summary = Assert.IsType>(Data(completed)["doSummary"]); + Assert.Equal(600L, summary["totalBytesDownloaded"]); // the real install's bytes, NOT the 4000 CLIENTUPDATE bytes + } + + [Fact] + public void ResumePreinstalled_suppresses_duplicate_emit_but_stays_armed() + { + using var rig = new Rig(PreinstalledConsumer()); + rig.Sut.CoreBinariesProbe = _ => true; + + // Simulate a restart where preinstalled was already reported in a previous run. + rig.Sut.ResumePreinstalled(new OfficeInstallStateData { State = OfficeInstallStateData.StatePreinstalled }); + + // Resident-consumer signal this run must NOT re-emit preinstalled. + rig.Sut.OnWorkerStarted(); + Assert.Empty(rig.OfficeEvents()); + + // But a fresh enterprise install still starts (armed). + rig.Current = ActiveStreaming(); + rig.Sut.CoreBinariesProbe = _ => false; + rig.Sut.OnWorkerStarted(); + Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(Assert.Single(rig.OfficeEvents()))); + } + + // ---------------------------------------------------------------- product classifier + + [Theory] + [InlineData(true, "O365HomePremRetail", "OneNoteFreeRetail")] // OEM consumer inbox + [InlineData(true, "OneNoteFreeRetail")] + [InlineData(true, "HomeBusinessRetail")] // consumer perpetual — NOT the managed O365Business + [InlineData(true, "HomeStudent2019Retail")] + [InlineData(false, "O365ProPlusRetail")] // M365 Apps for enterprise + [InlineData(false, "O365BusinessRetail")] // M365 Apps for business (managed) + [InlineData(false, "O365HomePremRetail", "O365ProPlusRetail")] // mixed → managed wins + [InlineData(false, "SomeUnknownSku")] // unknown → not consumer + public void IsConsumerInboxProductSet_classifies_consumer_vs_managed(bool expected, params string[] products) + { + Assert.Equal(expected, OfficeProductClassifier.IsConsumerInboxProductSet(products)); + } + + [Fact] + public void IsConsumerInboxProductSet_empty_is_not_consumer() + { + Assert.False(OfficeProductClassifier.IsConsumerInboxProductSet(new List())); + Assert.False(OfficeProductClassifier.IsConsumerInboxProductSet(null)); + } + // ---------------------------------------------------------------- error-code heuristic [Theory] [InlineData("0x80070005", true)] // hex HRESULT [InlineData("17002", true)] // decimal C2R exit code + [InlineData("30015", true)] // decimal C2R exit code [InlineData("-2147024891", true)] // negative decimal HRESULT + [InlineData("2", false)] // implausibly small positive — a state/enum, not an error + [InlineData("9", false)] + [InlineData("999", false)] // just below the plausibility floor + [InlineData("1000", true)] // at the floor [InlineData("0x0", false)] // zero hex [InlineData("0", false)] // zero decimal [InlineData("Success", false)] // benign textual value (e.g. Result=Success) @@ -395,7 +595,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office [InlineData("InProgress", false)] [InlineData("", false)] [InlineData(null, false)] - public void IsNonZeroNumericCode_only_treats_nonzero_numbers_as_errors(string? value, bool expected) + public void IsNonZeroNumericCode_only_treats_plausible_nonzero_numbers_as_errors(string? value, bool expected) { Assert.Equal(expected, OfficeInstallDetector.IsNonZeroNumericCode(value!)); } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallStatePersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallStatePersistenceTests.cs index 05270f44..c7b56a49 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallStatePersistenceTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallStatePersistenceTests.cs @@ -96,6 +96,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office [InlineData(OfficeInstallStateData.StateActive, false)] [InlineData(OfficeInstallStateData.StateCompleted, true)] [InlineData(OfficeInstallStateData.StateFailed, true)] + [InlineData(OfficeInstallStateData.StatePreinstalled, false)] // persisted for dedup, but re-arms public void IsTerminal_only_for_completed_and_failed(string state, bool expected) { Assert.Equal(expected, new OfficeInstallStateData { State = state }.IsTerminal); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallDetector.cs index e9a5c52f..6a7d6f6a 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallDetector.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallDetector.cs @@ -59,6 +59,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office // names against a field enrollment and widen only if needed. private static readonly string[] ErrorValueHints = { "errorcode", "hresult", "exitcode", "lasterror", "failurecode" }; + // Install-class C2R scenario subkey names. Only these anchor an error code (a failure in a + // CLIENTUPDATE / UPDATE / maintenance scenario must not fail an install). Deliberately narrow + // — the Autopilot enrollment cares about the fresh product lay-down, not client self-updates. + private static readonly string[] InstallScenarioNames = { "INSTALL" }; + + private static bool IsInstallScenario(string? name) + => name != null && InstallScenarioNames.Any(s => string.Equals(s, name, StringComparison.OrdinalIgnoreCase)); + // Core Office app binaries used as the on-disk completion proof. ANY of these present under // {InstallationPath}\root\* means the C2R lay-down finished — "any of", because a deployment can // exclude products (e.g. no Outlook), so requiring all would miss legitimate completions. @@ -93,6 +101,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office internal enum CompletionOutcome { Completed, Failed, NotYet } private DetectorState _state = DetectorState.Idle; + private bool _preinstalledReported; // office_preinstalled_detected already emitted (emit-once guard) private DateTime? _startedAtUtc; private string? _startedTrigger; private OfficeDoSample? _lastDo; @@ -141,6 +150,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office } } + /// + /// Restores the "preinstalled already reported" guard after an agent restart so resident inbox + /// Office does not re-emit office_preinstalled_detected every reboot — while leaving the + /// detector Idle/armed so a later fresh (enterprise) install in this enrollment is still caught. + /// + internal void ResumePreinstalled(OfficeInstallStateData state) + { + if (state == null || state.State != OfficeInstallStateData.StatePreinstalled) return; + lock (_lock) + { + if (_state != DetectorState.Idle) return; + _preinstalledReported = true; + _logger.Info($"[{SourceName}] resumed preinstalled-reported guard (armed for a later fresh install)"); + } + } + /// /// Surfaces the C2R InstallationPath to the host (once) as soon as it is known, so the /// host can arm the on the correct tree. Caller holds the lock. @@ -189,7 +214,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office if (_state == DetectorState.Idle) { - if (snap.ActiveScenarioPresent) BeginIfIdle("registry", snap); + // Start from the registry ONLY for an install-class scenario. A CLIENTUPDATE / UPDATE + // scenario key (the C2R client self-update / maintenance) must not open an install + // lifecycle. The early DO-job trigger (OnOfficeDoSample) still catches a real install + // before its INSTALL key is written — the common case (47% of field starts). + if (snap.InstallScenarioPresent) BeginIfIdle("registry", snap); return; } EvaluateActive(snap); @@ -209,9 +238,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office if (sample == null) return; lock (_lock) { - _lastDo = sample; - var peakGrew = _peakDo == null || sample.TotalBytesDownloaded > _peakDo.TotalBytesDownloaded; - if (peakGrew) _peakDo = sample; if (_state == DetectorState.Terminal) return; if (_state == DetectorState.Idle) @@ -220,11 +246,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office var snapStart = ReadSnapshotSafe(); if (snapStart == null) return; BeginIfIdle("do", snapStart); + // Seed the DO summary from the triggering sample ONLY if a real lifecycle actually + // opened. A preinstalled short-circuit stays Idle — capturing its CLIENTUPDATE bytes + // here would leak them into a LATER real install's doSummary (which is built from + // _peakDo). The seed is persisted directly (it is the lifecycle's first DO sample). + if (_state == DetectorState.Active) + { + _lastDo = sample; + _peakDo = sample; + PersistState(OfficeInstallStateData.StateActive); + } return; } // Active: no progress event — the sample only updates the DO summary data. A grown // peak is re-persisted (throttled) so a mid-install agent restart keeps the doSummary. + _lastDo = sample; + var peakGrew = _peakDo == null || sample.TotalBytesDownloaded > _peakDo.TotalBytesDownloaded; + if (peakGrew) _peakDo = sample; if (peakGrew && _statePersistence != null && (!_lastStatePersistUtc.HasValue || (_clock.UtcNow - _lastStatePersistUtc.Value).TotalSeconds >= PeakPersistThrottleSeconds)) @@ -294,6 +333,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office private void BeginIfIdle(string trigger, OfficeC2RSnapshot snap) { if (_state != DetectorState.Idle) return; + + // Already-resident detection — the precision gate. When the core Office binaries are + // ALREADY on disk at the very first signal, this C2R activity is NOT a fresh install: + // Office was laid down before the detector opened. Two flavours: + // * consumer / OEM-inbox product set → surface an informational office_preinstalled_detected + // (the C2R client running a background CLIENTUPDATE on pre-provisioned Office — field + // session fa526757), NOT a started/failed pair. + // * enterprise product set → fall through to the existing started→completed path (Office + // CSP / Win32-wrapper re-run on already-present Office — field session a7525e97). + // Anchored on the filesystem, NOT the StreamingFinished registry value (absent on some + // machines → reads false), so it holds even when that value is missing. + // + // Crucially this does NOT latch a terminal: an enrollment commonly UNINSTALLS the inbox + // Office and lays down a fresh (enterprise) Microsoft 365 Apps afterwards, which we must + // still report. So we emit the informational fact ONCE (idempotent guard) and stay Idle — + // a later fresh install (enterprise product set, or a real download with no binaries on + // disk yet) opens a normal started→completed lifecycle from here. + if (ProbeCoreBinaries(snap.InstallationPath) + && OfficeProductClassifier.IsConsumerInboxProductSet(snap.Products)) + { + if (!_preinstalledReported) + { + _preinstalledReported = true; + _startedAtUtc = _clock.UtcNow; + _startedTrigger = trigger; + EmitPreinstalled(snap); + PersistState(OfficeInstallStateData.StatePreinstalled); + } + return; // stay Idle (armed) — never open an install lifecycle for resident inbox Office + } + _state = DetectorState.Active; _startedAtUtc = _clock.UtcNow; _startedTrigger = trigger; @@ -330,6 +400,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office PersistState(OfficeInstallStateData.StateFailed); } + /// + /// Emits the informational office_preinstalled_detected for an Office that was already + /// resident on disk at the first signal (OEM/consumer inbox Office running a background + /// CLIENTUPDATE/maintenance scenario). This is NOT an enrollment install — Info severity, no + /// started/failed pair. The caller has set the emit-once guard + start metadata and deliberately + /// leaves the detector Idle/armed (no terminal latch) so a later fresh install is still detected. + /// + private void EmitPreinstalled(OfficeC2RSnapshot snap) + { + var data = BuildPayload(snap, "Preinstalled", isTerminal: true); + if (!string.IsNullOrEmpty(_startedTrigger)) data["startedTrigger"] = _startedTrigger!; + // Why we classified it as preinstalled — core binaries already on disk at first signal. + data["reason"] = "office_already_resident"; + + var product = snap.Products != null && snap.Products.Count > 0 ? string.Join(",", snap.Products) : AppName; + _post.Emit(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.OfficePreinstalledDetected, + Severity = EventSeverity.Info, + Source = SourceName, + Phase = EnrollmentPhase.Unknown, + Message = $"{SourceName}: {product} already present (pre-installed OEM/consumer Office — no enrollment install)", + Data = data, + ImmediateUpload = true, + }); + } + /// /// Persists the lifecycle state (fail-soft). Active and the two terminals are persisted; /// deliberately is NOT — it fires on every dispose/shutdown @@ -447,6 +546,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office { "streamingFinished", snap.StreamingFinished }, { "officeC2RClientRunning", snap.OfficeC2RClientRunning }, { "errorCode", (object?)snap.ErrorCode ?? string.Empty }, + { "errorSource", (object?)snap.ErrorSource ?? string.Empty }, { "installationPath", snap.InstallationPath ?? string.Empty }, { "scanErrors", (snap.Errors ?? new List()).ToList() }, }; @@ -587,6 +687,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office foreach (var scenarioName in scenarioNames) { + // Attribute an error code ONLY from an install-class scenario. A failure value + // in a CLIENTUPDATE / UPDATE / maintenance scenario (the C2R client self-update + // and similar) must never fail an Office *install* — field session fa526757 / + // the CLIENTUPDATE errorCode "2" false positive. + var isInstallScenario = IsInstallScenario(scenarioName); + if (isInstallScenario && snap.InstallScenarioName == null) + { + snap.InstallScenarioPresent = true; + snap.InstallScenarioName = scenarioName; + } + try { using (var scenarioKey = scenarioRoot.OpenSubKey(scenarioName, writable: false)) @@ -598,11 +709,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office if (value == null) continue; snap.ScenarioValues[$"{scenarioName}\\{valueName}"] = value; - if (snap.ErrorCode == null + if (isInstallScenario && snap.ErrorCode == null && ErrorValueHints.Any(h => valueName.IndexOf(h, StringComparison.OrdinalIgnoreCase) >= 0) && IsNonZeroNumericCode(value)) { snap.ErrorCode = value; + snap.ErrorSource = $"{scenarioName}\\{valueName}"; } } } @@ -660,10 +772,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office return v == "1" || v.Equals("true", StringComparison.OrdinalIgnoreCase); } + // Below this, a bare positive decimal is treated as an implausible failure code (a state / + // phase / mode / retry-count enum, not an error). Real C2R failures are HRESULTs (hex or + // negative decimal) or 5-digit C2R codes (17002, 30015, 30088…) — all >= this floor. Field + // session fa526757 / the CLIENTUPDATE "2" false positive motivated this. + private const long MinPlausibleDecimalErrorCode = 1000; + /// - /// True only when is a non-zero numeric or hex (0x…) code. Textual - /// values such as "Success" / "Completed" / "InProgress" are NOT error codes and return false, - /// so a benign "Result=Success" never masquerades as a failure. + /// True only when is a PLAUSIBLE non-zero error code: a non-zero hex + /// (0x…) value, a negative decimal HRESULT, or a positive decimal at/above + /// . Textual values ("Success" / "Completed" / + /// "InProgress") and implausibly small positive decimals (e.g. "2") return false, so a benign + /// status/enum value never masquerades as a failure. /// internal static bool IsNonZeroNumericCode(string value) { @@ -673,8 +793,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office { return long.TryParse(v.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hex) && hex != 0; } - // Decimal (HRESULTs can be large or negative); reject anything non-numeric. - return long.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dec) && dec != 0; + // Decimal: reject non-numeric, zero, and implausibly small positive values; keep negative + // HRESULTs and large positive C2R codes. + if (!long.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dec) || dec == 0) return false; + return dec < 0 || dec >= MinPlausibleDecimalErrorCode; } } @@ -694,8 +816,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office public bool StreamingFinished { get; set; } public bool ActiveScenarioPresent { get; set; } public string? ActiveScenarioName { get; set; } + /// True when an install-class scenario (INSTALL) subkey is present. + public bool InstallScenarioPresent { get; set; } + /// The install-class scenario subkey name, when present (else null). + public string? InstallScenarioName { get; set; } public Dictionary ScenarioValues { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); public string? ErrorCode { get; set; } + /// Which scenario\value produced — for diagnosis. + public string? ErrorSource { get; set; } public bool OfficeC2RClientRunning { get; set; } public List Errors { get; } = new List(); } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallStatePersistence.cs index ea10391d..54e83f39 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallStatePersistence.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallStatePersistence.cs @@ -92,8 +92,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office public const string StateActive = "Active"; public const string StateCompleted = "Completed"; public const string StateFailed = "Failed"; + // The C2R activity was on an already-resident Office (OEM/consumer inbox) — a + // office_preinstalled_detected went out, no install lifecycle was opened. Persisted ONLY to + // suppress a duplicate emit across reboots; deliberately NOT terminal (see IsTerminal), because + // an enrollment commonly uninstalls the inbox Office and lays down a fresh one afterwards — the + // next run must re-arm the detector to catch that real install. + public const string StatePreinstalled = "Preinstalled"; - /// , or . + /// , , or . public string? State { get; set; } public DateTime? StartedAtUtc { get; set; } @@ -102,6 +108,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office /// Highest-bytes DO sample seen — basis for the doSummary on a resumed completion. public OfficeDoPeakData? PeakDo { get; set; } + // Preinstalled is intentionally excluded — it is persisted for emit-once dedup but must NOT + // block the next run from arming the detector (a later fresh install must still be detected). [JsonIgnore] public bool IsTerminal => State == StateCompleted || State == StateFailed; } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeProductClassifier.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeProductClassifier.cs new file mode 100644 index 00000000..ce2024a2 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeProductClassifier.cs @@ -0,0 +1,48 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office +{ + /// + /// Classifies a C2R ProductReleaseIds set as consumer / OEM-inbox Office vs an IT-managed + /// deployment. Used to label an already-resident Office (core binaries present at the first + /// signal) as preinstalled inbox Office — e.g. the O365HomePremRetail + OneNoteFreeRetail + /// that ship pre-provisioned on many OEM Windows images and run background CLIENTUPDATE scenarios + /// during an enrollment — rather than mis-reporting it as a Microsoft 365 Apps install/failure. + /// + internal static class OfficeProductClassifier + { + // Enterprise / IT-managed C2R product markers (Microsoft 365 Apps for enterprise/business, + // volume). When ANY product carries one of these the set is treated as managed, never + // "consumer inbox" — even if a consumer SKU is co-listed. NOTE: the business marker is the + // specific "O365Business" prefix (the managed "Microsoft 365 Apps for business" id, e.g. + // O365BusinessRetail), NOT a bare "Business" — otherwise the consumer perpetual HomeBusiness*Retail + // SKUs would be wrongly caught here before the consumer markers below. + private static readonly string[] ManagedMarkers = { "ProPlus", "O365Business", "Volume" }; + + // Consumer / OEM-preinstall SKU markers (substring, case-insensitive). + private static readonly string[] ConsumerMarkers = + { + "HomePrem", "HomeStudent", "HomeBusiness", "Personal", "OneNoteFree", "Professional", + }; + + /// + /// True when the product set is non-empty AND every entry looks like a consumer / OEM-inbox SKU + /// and none is an enterprise/business/volume SKU. Conservative: an unknown SKU (matching neither + /// list) makes the set NOT consumer, so an unrecognized managed product is never mislabeled. + /// + internal static bool IsConsumerInboxProductSet(IReadOnlyList? products) + { + if (products == null || products.Count == 0) return false; + foreach (var p in products) + { + if (string.IsNullOrWhiteSpace(p)) return false; + if (ManagedMarkers.Any(m => p.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0)) return false; + if (!ConsumerMarkers.Any(m => p.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0)) return false; + } + return true; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index 975036b9..631d1ec3 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -298,9 +298,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration // several reboots, and Scenario\INSTALL persists post-install / OfficeC2RClient.exe // runs again for update checks / Office updates stream from the same CDN — all three // start triggers would falsely re-open the window and emit a duplicate - // started+completed pair every restart). A persisted terminal means the install was - // already reported → don't arm the detector at all this run; a persisted Active is - // resumed inside the host (no second started, missed completion delivered late). + // started+completed pair every restart). A persisted terminal (Completed/Failed) means + // the install was already reported → don't arm the detector at all this run; a persisted + // Active is resumed inside the host (no second started, missed completion delivered late). + // A persisted Preinstalled is NOT terminal — the host is still armed (an enrollment often + // uninstalls the inbox Office and installs a fresh one), it only suppresses a duplicate + // office_preinstalled_detected via ResumePreinstalled. var officeStatePersistence = new OfficeInstallStatePersistence(_stateDirectory, logger); var officeState = officeStatePersistence.Load(); if (officeState != null && officeState.IsTerminal) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OfficeInstallDetectorHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OfficeInstallDetectorHost.cs index ea9dcf4c..1b46f16e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OfficeInstallDetectorHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OfficeInstallDetectorHost.cs @@ -33,6 +33,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// An explicit error code → failed. No binary ever appears → the lifecycle simply stays open and is /// abandoned silently on dispose (never a false completed/failed). /// + /// + /// Already-resident short-circuit: when the core Office binaries are already on disk at the + /// first signal, the detector does not open an install lifecycle — a consumer/OEM-inbox product set + /// emits an informational office_preinstalled_detected (no binary watcher armed), enterprise + /// Office falls through to the synchronous started→completed path. See . + /// /// internal sealed class OfficeInstallDetectorHost : ICollectorHost { @@ -91,6 +97,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration public void Start() { + // Restore the preinstalled emit-once guard BEFORE arming any watcher. The process watcher's + // startup probe can synchronously fire Started (→ OnWorkerStarted → BeginIfIdle) when + // OfficeC2RClient.exe is already running, which would otherwise re-emit + // office_preinstalled_detected for the resident inbox Office. ResumePreinstalled only sets + // the guard (no watcher/InstallationPath interaction), so it is safe to run this early. + if (_resumeState != null && _resumeState.State == OfficeInstallStateData.StatePreinstalled) + _detector.ResumePreinstalled(_resumeState); + // Arm the registry watcher up front (not at worker-start) so the early Scenario\INSTALL key // is caught before the (late) worker process. The watcher's bootstrap fallback handles the // case where the ClickToRun key does not exist yet on a clean first install. @@ -102,11 +116,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration } _processWatcher.Start(); - // Resume a lifecycle left open by a previous agent run (enrollments span reboots) AFTER - // the watchers are armed: the resume surfaces the InstallationPath, which arms the binary - // watcher — when Office finished installing while the agent was down, its initial scan - // completes the lifecycle synchronously and the teardown disposes the watchers again. - if (_resumeState != null) _detector.ResumeActive(_resumeState); + // Resume a persisted *Active* lifecycle (enrollments span reboots) AFTER the watchers are + // armed: the resume surfaces the InstallationPath, which arms the binary watcher — when + // Office finished installing while the agent was down, its initial scan completes the + // lifecycle synchronously and the teardown disposes the just-armed watchers again. + if (_resumeState != null && _resumeState.State != OfficeInstallStateData.StatePreinstalled) + _detector.ResumeActive(_resumeState); } private void OnWorkerStarted(object? sender, EventArgs e) => _detector.OnWorkerStarted(); diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index 1e2f5159..57b079a3 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -69,6 +69,7 @@ export const EVENT_TYPES_CATALOG = { 'office_install_progress', 'office_install_completed', 'office_install_failed', + 'office_preinstalled_detected', ], script_events: ['script_started', 'script_completed', 'script_failed'], network_events: [ diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index d09d0335..caa4d8c6 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -324,6 +324,10 @@ namespace AutopilotMonitor.Shared public const string OfficeInstallProgress = "office_install_progress"; public const string OfficeInstallCompleted = "office_install_completed"; public const string OfficeInstallFailed = "office_install_failed"; + // Emitted instead of a started/failed pair when the C2R activity is on an Office that is + // ALREADY fully resident on disk at the first signal (OEM/consumer inbox Office running a + // background CLIENTUPDATE/maintenance scenario). Informational — NOT an enrollment failure. + public const string OfficePreinstalledDetected = "office_preinstalled_detected"; // Stall detection (Ebene 2 — StallProbeCollector) public const string StallProbeCheck = "stall_probe_check"; // Trace heartbeat from Probe 2 (15 min) when no anomaly found diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts index e86672de..19f20164 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts @@ -96,6 +96,8 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [ description: "Office C2R install completed (streaming finished, scenario cleared); carries version reached and duration." }, { value: "office_install_failed", label: "office_install_failed", category: "app", description: "Office C2R install ended without reaching completion, or a failure code was observed." }, + { value: "office_preinstalled_detected", label: "office_preinstalled_detected", category: "app", + description: "Office was already fully resident on disk at the first signal (OEM/consumer inbox Office running a background CLIENTUPDATE) — informational, not an enrollment install or failure." }, // -------- Scripts -------- { value: "script_started", label: "script_started", category: "script", diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx index b806e90a..556f3b60 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -600,7 +600,7 @@ export default function SessionDetailPage() { e.eventType === "app_install_started" || e.eventType === "app_install_completed" || e.eventType === "app_install_failed" || e.eventType === "app_install_postponed" || e.eventType === "app_install_skipped" - || e.eventType === "office_install_started" || e.eventType === "office_install_completed" || e.eventType === "office_install_failed" + || e.eventType === "office_install_started" || e.eventType === "office_install_completed" || e.eventType === "office_install_failed" || e.eventType === "office_preinstalled_detected" || e.eventType === "realmjoin_package_started" || e.eventType === "realmjoin_package_completed" )} summaryStats={appSummaryStats} diff --git a/src/Web/autopilot-monitor-web/components/InstallProgress.tsx b/src/Web/autopilot-monitor-web/components/InstallProgress.tsx index 5dcb7c4f..178aa321 100644 --- a/src/Web/autopilot-monitor-web/components/InstallProgress.tsx +++ b/src/Web/autopilot-monitor-web/components/InstallProgress.tsx @@ -32,7 +32,7 @@ const ESP_APPS_INSTALL_FAILURE = "esp_apps_install_failure"; interface InstallItem { appName: string; appId: string; - state: "Installing" | "Installed" | "Failed" | "Postponed" | "Skipped"; + state: "Installing" | "Installed" | "Failed" | "Postponed" | "Skipped" | "Preinstalled"; startedAt?: string; completedAt?: string; durationMs?: number; @@ -206,6 +206,24 @@ export default function InstallProgress({ events, summaryStats }: InstallProgres firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++, eventData: d, }); + } else if (type === "office_preinstalled_detected") { + // Office was already fully resident on disk at the first signal (OEM/consumer inbox Office + // running a background CLIENTUPDATE) — informational, not an enrollment install or failure. + // Don't overwrite a real terminal state if one somehow co-exists. + if (existing?.state === "Installed" || existing?.state === "Failed") continue; + installMap.set(appName, { + appName, + appId, + state: "Preinstalled", + completedAt: eventTs, + isCompleted: true, + isError: false, + isLikelyStuck: false, + isDetectionFailure: false, + isInstallFailure: false, + firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++, + eventData: d, + }); } else if (evt.eventType === "app_install_postponed") { // Don't downgrade from Installed to Postponed. if (existing?.state === "Installed") continue; @@ -384,13 +402,15 @@ function InstallItemRow({ item }: { item: InstallItem }) { // have a concrete HRESULT and are confirmed errors → red, same as any other. const containerClass = item.state === "Skipped" ? "bg-gray-50 border border-gray-300" - : item.isLikelyStuck - ? "bg-orange-50 border border-orange-200" - : item.isError - ? "bg-red-50 border border-red-200" - : item.isCompleted - ? "bg-green-50 border border-green-200" - : "bg-gray-50 border border-gray-200"; + : item.state === "Preinstalled" + ? "bg-sky-50 border border-sky-200" + : item.isLikelyStuck + ? "bg-orange-50 border border-orange-200" + : item.isError + ? "bg-red-50 border border-red-200" + : item.isCompleted + ? "bg-green-50 border border-green-200" + : "bg-gray-50 border border-gray-200"; return (
@@ -400,6 +420,11 @@ function InstallItemRow({ item }: { item: InstallItem }) { + ) : item.state === "Preinstalled" ? ( + // Info circle — already-resident Office, neither install nor failure. + + + ) : item.isLikelyStuck ? ( // Question-mark inside circle — explicit "we don't actually know" iconography, // distinct from the hard X used for confirmed failures. @@ -455,6 +480,14 @@ function InstallItemRow({ item }: { item: InstallItem }) { {item.state === "Postponed" && ( Postponed )} + {item.state === "Preinstalled" && ( + + Pre-installed + + )}
{elapsedMs != null && elapsedMs > 0 && (