diff --git a/.gitignore b/.gitignore index 89bfc779..ae826045 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ tasks/ # Produced by /cleanup-table skill — never commit deletion payloads db-backups/ plans/REFACTOR_AGENT_V2.md +.claude/scheduled_tasks.lock diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ShellCoreTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ShellCoreTrackerTests.cs index 320796ba..1c3dbfb6 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ShellCoreTrackerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ShellCoreTrackerTests.cs @@ -118,7 +118,7 @@ namespace AutopilotMonitor.Agent.Core.Tests.Collectors "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success'", DateTime.UtcNow.AddSeconds(1), "prov", false); - Assert.Single(_captured.ToList().Where(e => e.EventType == "whiteglove_complete")); + Assert.Single(_captured, e => e.EventType == "whiteglove_complete"); Assert.Equal(1, _whiteGloveCompletedCount); Assert.True(t.IsWhiteGloveDetectedForTest); // WhiteGlove does NOT trigger FinalizingSetup diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs index 24ce8897..4a958c72 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs @@ -139,7 +139,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security var wrongHash = new string('f', 64); var triggerCount = 0; - string capturedZipHash = null; + string? capturedZipHash = null; bool capturedDowngrade = false; var triggerDone = new ManualResetEventSlim(false); Func trigger = (zip, down) => diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs index c67aa43f..d27cdb32 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs @@ -146,10 +146,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security private sealed class FakeApiClient : BackendApiClient { private readonly Func _respond; - private readonly Action _onRegister; + private readonly Action? _onRegister; public int CallCount; - public FakeApiClient(Func respond, Action onRegister = null) + public FakeApiClient(Func respond, Action? onRegister = null) { _respond = respond; _onRegister = onRegister; diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 9b252fd0..4699eb3c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -113 +116 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 41495a48..e74c832b 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1079 +1081 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetAgentConfigFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetAgentConfigFunctionTests.cs new file mode 100644 index 00000000..89cc0c21 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetAgentConfigFunctionTests.cs @@ -0,0 +1,33 @@ +using AutopilotMonitor.Functions.Functions.Config; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for — the pure decision logic that picks +/// the V1 vs V2 hash-oracle fields based on the X-Agent-Version header value. +/// +public class GetAgentConfigFunctionTests +{ + [Theory] + // V2 agents (major >= 2) — must route to the V2 hash oracle. + [InlineData("2.0.114", true)] + [InlineData("2.0.0", true)] + [InlineData("2.1.3", true)] + [InlineData("2.0.114+06bbf13dbeedc", true)] // SemVer build-metadata suffix + [InlineData("3.0.0", true)] + // V1 agents (major < 2) — must stay on the legacy hash oracle. + [InlineData("1.0.1041", false)] + [InlineData("1.5.0", false)] + [InlineData("0.9.0", false)] + // Backward compat — missing/unparsable headers default to V1 so existing agents keep working. + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("not-a-version", false)] + [InlineData("vNEXT", false)] + public void IsV2Client_MajorVersionGate(string? agentVersion, bool expectedV2) + { + var actual = GetAgentConfigFunction.IsV2Client(agentVersion); + Assert.Equal(expectedV2, actual); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SlaMetricsServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SlaMetricsServiceTests.cs index 7ee50ae5..0aa331e7 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SlaMetricsServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SlaMetricsServiceTests.cs @@ -94,7 +94,7 @@ public class SlaMetricsServiceTests Assert.Equal(100, result.CurrentWeek.SuccessRate); Assert.True(result.CurrentWeek.SuccessRateMet); - Assert.Empty(result.Violators.Where(v => v.ViolationType == "Failed")); + Assert.DoesNotContain(result.Violators, v => v.ViolationType == "Failed"); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 947cc8f8..95f5c033 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -563,6 +563,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "LatestAgentExeSha256", config.LatestAgentExeSha256 ?? string.Empty }, { "AllowAgentDowngrade", config.AllowAgentDowngrade }, { "LatestBootstrapScriptVersion", config.LatestBootstrapScriptVersion ?? string.Empty }, + // V2 agent binary integrity (separate release line — written by V2 build scripts) + { "LatestAgentV2Version", config.LatestAgentV2Version ?? string.Empty }, + { "LatestAgentV2Sha256", config.LatestAgentV2Sha256 ?? string.Empty }, + { "LatestAgentV2ExeSha256", config.LatestAgentV2ExeSha256 ?? string.Empty }, + { "LatestBootstrapV2ScriptVersion", config.LatestBootstrapV2ScriptVersion ?? string.Empty }, // Rate limiting per-role settings { "UserRateLimitRequestsPerMinute", config.UserRateLimitRequestsPerMinute }, { "GlobalAdminRateLimitRequestsPerMinute", config.GlobalAdminRateLimitRequestsPerMinute }, @@ -617,6 +622,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage LatestAgentExeSha256 = entity.GetString("LatestAgentExeSha256") ?? string.Empty, AllowAgentDowngrade = entity.GetBoolean("AllowAgentDowngrade") ?? false, LatestBootstrapScriptVersion = entity.GetString("LatestBootstrapScriptVersion") ?? string.Empty, + // V2 agent binary integrity (separate release line) + LatestAgentV2Version = entity.GetString("LatestAgentV2Version") ?? string.Empty, + LatestAgentV2Sha256 = entity.GetString("LatestAgentV2Sha256") ?? string.Empty, + LatestAgentV2ExeSha256 = entity.GetString("LatestAgentV2ExeSha256") ?? string.Empty, + LatestBootstrapV2ScriptVersion = entity.GetString("LatestBootstrapV2ScriptVersion") ?? string.Empty, // Rate limiting per-role settings UserRateLimitRequestsPerMinute = entity.GetInt32("UserRateLimitRequestsPerMinute") ?? 120, GlobalAdminRateLimitRequestsPerMinute = entity.GetInt32("GlobalAdminRateLimitRequestsPerMinute") ?? 600, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 8a027f6a..caf0a006 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -102,6 +102,44 @@ namespace AutopilotMonitor.Functions.Functions.Config } } + /// + /// Determines whether an X-Agent-Version header advertises the V2 release line. + /// V2 agents use a separate hash-oracle (LatestAgentV2Sha256 / LatestAgentV2ExeSha256) + /// so they are not served V1 hashes and vice versa. + /// + /// + /// Accepts SemVer-ish version strings like "2.0.114" or "2.0.114+abc123". + /// Missing or unparsable header → treat as V1 (backward compat for older agents + /// that do not send the header). + /// + internal static bool IsV2Client(string? agentVersion) + { + if (string.IsNullOrWhiteSpace(agentVersion)) + return false; + + var dot = agentVersion.IndexOf('.'); + var majorStr = dot > 0 ? agentVersion.Substring(0, dot) : agentVersion; + return int.TryParse(majorStr, out var major) && major >= 2; + } + + /// + /// Returns the (ZipSha256, ExeSha256) pair appropriate for the calling agent. + /// Reads the X-Agent-Version header and dispatches to the V1 or V2 fields on + /// . + /// + internal static (string ZipSha256, string ExeSha256) SelectAgentHashesForClient( + HttpRequestData req, + AutopilotMonitor.Shared.Models.AdminConfiguration adminConfig) + { + var agentVersion = req.Headers.Contains("X-Agent-Version") + ? req.Headers.GetValues("X-Agent-Version").FirstOrDefault() + : null; + + return IsV2Client(agentVersion) + ? (adminConfig.LatestAgentV2Sha256, adminConfig.LatestAgentV2ExeSha256) + : (adminConfig.LatestAgentSha256, adminConfig.LatestAgentExeSha256); + } + /// /// Core config logic: fetch tenant + admin config, gather rules, IME patterns. /// Called by both the cert-auth Run() method and the bootstrap wrapper. @@ -138,6 +176,11 @@ namespace AutopilotMonitor.Functions.Functions.Config var tenantDiagPaths = tenantConfig.GetDiagnosticsLogPaths(); var diagLogPaths = globalDiagPaths.Concat(tenantDiagPaths).ToList(); + // Select V1 vs V2 hash oracle based on the X-Agent-Version header. + // V1 agents get LatestAgentSha256/ExeSha256; V2 agents get LatestAgentV2Sha256/V2ExeSha256. + // Response field names stay the same so the agent code is unchanged. + var (latestAgentSha256, latestAgentExeSha256) = SelectAgentHashesForClient(req, adminConfig); + var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new AgentConfigResponse { @@ -168,8 +211,8 @@ namespace AutopilotMonitor.Functions.Functions.Config EnableSoftwareInventoryAnalyzer = tenantConfig.EnableSoftwareInventoryAnalyzer ?? false, EnableIntegrityBypassAnalyzer = tenantConfig.EnableIntegrityBypassAnalyzer ?? true }, - LatestAgentSha256 = adminConfig.LatestAgentSha256, - LatestAgentExeSha256 = adminConfig.LatestAgentExeSha256, + LatestAgentSha256 = latestAgentSha256, + LatestAgentExeSha256 = latestAgentExeSha256, AllowAgentDowngrade = adminConfig.AllowAgentDowngrade, NtpServer = string.IsNullOrEmpty(tenantConfig.NtpServer) ? "time.windows.com" : tenantConfig.NtpServer, EnableTimezoneAutoSet = tenantConfig.EnableTimezoneAutoSet ?? false, diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs index 344b3855..ade64861 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs @@ -273,6 +273,34 @@ namespace AutopilotMonitor.Shared.Models /// public string LatestBootstrapScriptVersion { get; set; } = default!; + // ===== V2 AGENT HASH ORACLE ===== + // Separate release line from V1 so V2 clients are not served V1 hashes (and vice versa). + // Written by scripts/Deployment/V2/build_and_upload_release_agent_build.ps1. + // GetAgentConfigFunction selects V1 vs V2 based on the X-Agent-Version header's major version. + + /// + /// Version string of the latest published V2 agent (e.g. "2.0.114"). See for V1. + /// + public string LatestAgentV2Version { get; set; } = default!; + + /// + /// SHA-256 hash (lowercase hex) of the latest published V2 agent ZIP (AutopilotMonitor-Agent-V2.zip). + /// Mirror of for the V2 release line. + /// + public string LatestAgentV2Sha256 { get; set; } = default!; + + /// + /// SHA-256 hash (lowercase hex) of the latest published V2 agent EXE (AutopilotMonitor.Agent.V2.exe). + /// Mirror of for the V2 release line. + /// + public string LatestAgentV2ExeSha256 { get; set; } = default!; + + /// + /// Version string of the latest published V2 bootstrap script (Install-AutopilotMonitor-v2.ps1). + /// Mirror of for the V2 release line. + /// + public string LatestBootstrapV2ScriptVersion { get; set; } = default!; + // ===== MODERN DEPLOYMENT NOISE SUPPRESSION ===== /// diff --git a/src/Web/autopilot-monitor-web/types/adminConfig.ts b/src/Web/autopilot-monitor-web/types/adminConfig.ts index 0f2e6475..19bad815 100644 --- a/src/Web/autopilot-monitor-web/types/adminConfig.ts +++ b/src/Web/autopilot-monitor-web/types/adminConfig.ts @@ -24,6 +24,16 @@ export interface AdminConfiguration { opsAlertSlackWebhookUrl?: string; allowAgentDowngrade?: boolean; modernDeploymentHarmlessEventIdsJson?: string; + // Agent hash oracle (written by CI/CD / build scripts, surfaced read-only; must round-trip via Save to survive Replace) + latestAgentVersion?: string; + latestAgentSha256?: string; + latestAgentExeSha256?: string; + latestBootstrapScriptVersion?: string; + // V2 agent hash oracle (separate release line — written by scripts/Deployment/V2/*.ps1) + latestAgentV2Version?: string; + latestAgentV2Sha256?: string; + latestAgentV2ExeSha256?: string; + latestBootstrapV2ScriptVersion?: string; /** * Feature flag for the V2 Decision Engine index-table dual-write (Plan §M5.d). * When true, IngestTelemetryFunction enqueues telemetry-index-reconcile envelopes after