diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml
index c3d831e0..ebabb988 100644
--- a/.github/workflows/build-agent.yml
+++ b/.github/workflows/build-agent.yml
@@ -155,9 +155,22 @@ jobs:
$exeSha256Hash = (Get-FileHash "$stagingDir/AutopilotMonitor.Agent.exe" -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Host "EXE SHA-256: $exeSha256Hash"
- # Enrich version.json with SHA-256 hash (MSBuild only writes version, we add the hash post-ZIP)
+ # Parse bootstrap script version from Install-AutopilotMonitor.ps1
+ $bootstrapVersion = $null
+ $bootstrapContent = Get-Content "scripts/Bootstrap/Install-AutopilotMonitor.ps1" -Raw
+ if ($bootstrapContent -match '\$ScriptVersion\s*=\s*"([\d\.]+)"') {
+ $bootstrapVersion = $Matches[1]
+ Write-Host "Bootstrap script version: $bootstrapVersion"
+ } else {
+ Write-Warning "Could not parse bootstrap `$ScriptVersion from Install-AutopilotMonitor.ps1"
+ }
+
+ # Enrich version.json with SHA-256 hash + bootstrap version (MSBuild only writes version, we add the rest post-ZIP)
$versionObj = Get-Content "$agentOutput/version.json" -Raw | ConvertFrom-Json
$versionObj | Add-Member -NotePropertyName "sha256" -NotePropertyValue $sha256Hash -Force
+ if ($bootstrapVersion) {
+ $versionObj | Add-Member -NotePropertyName "bootstrapVersion" -NotePropertyValue $bootstrapVersion -Force
+ }
$versionObj | ConvertTo-Json -Compress | Set-Content "version.json" -Encoding UTF8
Write-Host "version.json content: $(Get-Content version.json)"
@@ -165,6 +178,7 @@ jobs:
"deploy_zip=$deployZip" >> $env:GITHUB_OUTPUT
"sha256=$sha256Hash" >> $env:GITHUB_OUTPUT
"exe_sha256=$exeSha256Hash" >> $env:GITHUB_OUTPUT
+ "bootstrap_version=$bootstrapVersion" >> $env:GITHUB_OUTPUT
- name: Upload to Azure Blob Storage
if: inputs.upload_blob
@@ -221,18 +235,23 @@ jobs:
$sha256 = "${{ steps.artifact.outputs.sha256 }}"
$exeSha256 = "${{ steps.artifact.outputs.exe_sha256 }}"
$agentVersion = "${{ steps.version.outputs.version }}"
+ $bootstrapVersion = "${{ steps.artifact.outputs.bootstrap_version }}"
$tableUrl = "https://autopilotmonitor.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')?$tableSasToken"
- $tableBody = @{
+ $tableBodyHash = @{
LatestAgentVersion = $agentVersion
LatestAgentSha256 = $sha256
LatestAgentExeSha256 = $exeSha256
- } | ConvertTo-Json
+ }
+ if ($bootstrapVersion) {
+ $tableBodyHash["LatestBootstrapScriptVersion"] = $bootstrapVersion
+ }
+ $tableBody = $tableBodyHash | ConvertTo-Json
Invoke-RestMethod -Uri $tableUrl -Method Merge -Headers @{
"Content-Type" = "application/json"
"Accept" = "application/json;odata=nometadata"
"If-Match" = "*"
} -Body $tableBody
- Write-Host "AdminConfiguration updated: version=$agentVersion, sha256=$sha256, exeSha256=$exeSha256"
+ Write-Host "AdminConfiguration updated: version=$agentVersion, sha256=$sha256, exeSha256=$exeSha256, bootstrapVersion=$bootstrapVersion"
}
- name: Upload build artifact
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 17ec7611..71fb6fcc 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-901
+903
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/LatestVersionsServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/LatestVersionsServiceTests.cs
new file mode 100644
index 00000000..877ac161
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/LatestVersionsServiceTests.cs
@@ -0,0 +1,172 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Services;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Tests for — the 12h-cached reader for the
+/// public version.json blob. Verifies cache hit/miss, forceRefresh bypass,
+/// and failure fallback without hammering the blob on every call.
+///
+public class LatestVersionsServiceTests
+{
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ public int CallCount { get; private set; }
+ public Func Responder { get; set; } = _ =>
+ new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"version\":\"1.0.706\",\"sha256\":\"abc\",\"bootstrapVersion\":\"1.1\"}")
+ };
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ CallCount++;
+ return Task.FromResult(Responder(CallCount));
+ }
+ }
+
+ private sealed class StubHttpClientFactory : IHttpClientFactory
+ {
+ private readonly StubHandler _handler;
+ public StubHttpClientFactory(StubHandler handler) => _handler = handler;
+ public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
+ }
+
+ private static (LatestVersionsService svc, StubHandler handler, IMemoryCache cache) Create()
+ {
+ var handler = new StubHandler();
+ var factory = new StubHttpClientFactory(handler);
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var svc = new LatestVersionsService(factory, cache, NullLogger.Instance);
+ return (svc, handler, cache);
+ }
+
+ [Fact]
+ public async Task GetAsync_FirstCall_FetchesFromBlob()
+ {
+ var (svc, handler, _) = Create();
+
+ var result = await svc.GetAsync(forceRefresh: false, CancellationToken.None);
+
+ Assert.NotNull(result);
+ Assert.Equal("1.0.706", result!.AgentVersion);
+ Assert.Equal("1.1", result.BootstrapVersion);
+ Assert.Equal("abc", result.AgentSha256);
+ Assert.False(result.FromCache);
+ Assert.Equal(1, handler.CallCount);
+ }
+
+ [Fact]
+ public async Task GetAsync_SecondCall_ReturnsCacheWithoutBlobCall()
+ {
+ var (svc, handler, _) = Create();
+
+ await svc.GetAsync(forceRefresh: false, CancellationToken.None);
+ var second = await svc.GetAsync(forceRefresh: false, CancellationToken.None);
+
+ Assert.NotNull(second);
+ Assert.True(second!.FromCache);
+ Assert.Equal(1, handler.CallCount); // NOT incremented
+ }
+
+ [Fact]
+ public async Task GetAsync_ForceRefresh_BypassesCache()
+ {
+ var (svc, handler, _) = Create();
+
+ await svc.GetAsync(forceRefresh: false, CancellationToken.None);
+ await svc.GetAsync(forceRefresh: true, CancellationToken.None);
+
+ Assert.Equal(2, handler.CallCount);
+ }
+
+ [Fact]
+ public async Task GetAsync_ForceRefresh_UpdatesCacheWithFreshValue()
+ {
+ var (svc, handler, _) = Create();
+
+ handler.Responder = _ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"version\":\"1.0.706\",\"bootstrapVersion\":\"1.1\"}")
+ };
+ await svc.GetAsync(forceRefresh: false, CancellationToken.None);
+
+ // Switch to new payload and force refresh
+ handler.Responder = _ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"version\":\"1.0.800\",\"bootstrapVersion\":\"1.2\"}")
+ };
+ var refreshed = await svc.GetAsync(forceRefresh: true, CancellationToken.None);
+
+ Assert.Equal("1.0.800", refreshed!.AgentVersion);
+ Assert.Equal("1.2", refreshed.BootstrapVersion);
+
+ // Subsequent non-force call should read the updated cache
+ var cached = await svc.GetAsync(forceRefresh: false, CancellationToken.None);
+ Assert.Equal("1.0.800", cached!.AgentVersion);
+ Assert.True(cached.FromCache);
+ Assert.Equal(2, handler.CallCount);
+ }
+
+ [Fact]
+ public async Task GetAsync_HttpFailure_ReturnsNullWithoutThrow()
+ {
+ var (svc, handler, _) = Create();
+ handler.Responder = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError);
+
+ var result = await svc.GetAsync(forceRefresh: false, CancellationToken.None);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task GetAsync_HttpFailure_IsShortCached_AvoidsHammering()
+ {
+ var (svc, handler, _) = Create();
+ handler.Responder = _ => new HttpResponseMessage(HttpStatusCode.InternalServerError);
+
+ await svc.GetAsync(forceRefresh: false, CancellationToken.None);
+ await svc.GetAsync(forceRefresh: false, CancellationToken.None);
+ await svc.GetAsync(forceRefresh: false, CancellationToken.None);
+
+ // Short-TTL null marker prevents repeat blob calls
+ Assert.Equal(1, handler.CallCount);
+ }
+
+ [Fact]
+ public async Task GetAsync_MalformedJson_ReturnsNull()
+ {
+ var (svc, handler, _) = Create();
+ handler.Responder = _ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("not json")
+ };
+
+ var result = await svc.GetAsync(forceRefresh: false, CancellationToken.None);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task GetAsync_PartialJson_MissingBootstrapVersion_StillReturnsAgentVersion()
+ {
+ var (svc, handler, _) = Create();
+ handler.Responder = _ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"version\":\"1.0.706\"}")
+ };
+
+ var result = await svc.GetAsync(forceRefresh: false, CancellationToken.None);
+
+ Assert.NotNull(result);
+ Assert.Equal("1.0.706", result!.AgentVersion);
+ Assert.Null(result.BootstrapVersion);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
index d782a6b6..794f4ff2 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
@@ -531,6 +531,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "LatestAgentVersion", config.LatestAgentVersion ?? string.Empty },
{ "LatestAgentSha256", config.LatestAgentSha256 ?? string.Empty },
{ "LatestAgentExeSha256", config.LatestAgentExeSha256 ?? string.Empty },
+ { "LatestBootstrapScriptVersion", config.LatestBootstrapScriptVersion ?? string.Empty },
// Rate limiting per-role settings
{ "UserRateLimitRequestsPerMinute", config.UserRateLimitRequestsPerMinute },
{ "GlobalAdminRateLimitRequestsPerMinute", config.GlobalAdminRateLimitRequestsPerMinute },
@@ -579,6 +580,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
LatestAgentVersion = entity.GetString("LatestAgentVersion") ?? string.Empty,
LatestAgentSha256 = entity.GetString("LatestAgentSha256") ?? string.Empty,
LatestAgentExeSha256 = entity.GetString("LatestAgentExeSha256") ?? string.Empty,
+ LatestBootstrapScriptVersion = entity.GetString("LatestBootstrapScriptVersion") ?? 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/GetLatestVersionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetLatestVersionsFunction.cs
new file mode 100644
index 00000000..3c8fc089
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetLatestVersionsFunction.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Net;
+using System.Threading.Tasks;
+using System.Web;
+using AutopilotMonitor.Functions.Services;
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.Azure.Functions.Worker.Http;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Functions.Config
+{
+ ///
+ /// Returns the latest published agent + bootstrap script versions.
+ /// Cached in-memory for 12h; supports ?refresh=true to bypass the cache.
+ /// Authenticated users only (read-only metadata).
+ ///
+ public class GetLatestVersionsFunction
+ {
+ private readonly ILogger _logger;
+ private readonly ILatestVersionsService _latestVersionsService;
+
+ public GetLatestVersionsFunction(
+ ILogger logger,
+ ILatestVersionsService latestVersionsService)
+ {
+ _logger = logger;
+ _latestVersionsService = latestVersionsService;
+ }
+
+ [Function("GetLatestVersions")]
+ public async Task Run(
+ [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/latest-versions")] HttpRequestData req)
+ {
+ try
+ {
+ var query = HttpUtility.ParseQueryString(req.Url.Query);
+ var refreshRaw = query["refresh"];
+ var forceRefresh = !string.IsNullOrEmpty(refreshRaw)
+ && (refreshRaw.Equals("true", StringComparison.OrdinalIgnoreCase) || refreshRaw == "1");
+
+ var versions = await _latestVersionsService.GetAsync(forceRefresh, req.FunctionContext.CancellationToken);
+
+ var response = req.CreateResponse(HttpStatusCode.OK);
+
+ // Force-refresh responses MUST NOT be browser-cached.
+ // Normal cached responses may be stored for up to 1h (aligns with 12h backend cache).
+ if (forceRefresh)
+ {
+ response.Headers.Add("Cache-Control", "no-store");
+ }
+ else
+ {
+ response.Headers.Add("Cache-Control", "public, max-age=3600");
+ }
+
+ await response.WriteAsJsonAsync(new
+ {
+ latestAgentVersion = versions?.AgentVersion,
+ latestBootstrapScriptVersion = versions?.BootstrapVersion,
+ latestAgentSha256 = versions?.AgentSha256,
+ fetchedAtUtc = versions?.FetchedAtUtc,
+ source = versions?.FromCache == true ? "cache" : "blob"
+ });
+ return response;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting latest versions");
+ var response = req.CreateResponse(HttpStatusCode.InternalServerError);
+ await response.WriteAsJsonAsync(new { error = "Internal server error" });
+ return response;
+ }
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs
index b5638762..2e765806 100644
--- a/src/Backend/AutopilotMonitor.Functions/Program.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Program.cs
@@ -113,6 +113,7 @@ builder.Services.AddHostedService(); // Initialize all
builder.Services.AddTableStorageDataAccess();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
index 9181eac8..138c89b7 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
@@ -124,6 +124,7 @@ public static class EndpointAccessPolicyCatalog
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUser),
new("GET", "feedback/status", EndpointPolicy.AuthenticatedUser),
new("POST", "feedback", EndpointPolicy.AuthenticatedUser),
+ new("GET", "config/latest-versions", EndpointPolicy.AuthenticatedUser),
// ── MemberRead (Admin + Operator, later + Viewer) ───────────────
new("GET", "raw/sessions", EndpointPolicy.MemberRead),
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/LatestVersionsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/LatestVersionsService.cs
new file mode 100644
index 00000000..9e00d489
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/LatestVersionsService.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Services
+{
+ ///
+ /// Exposes the latest published agent/bootstrap versions as read from the
+ /// public version.json blob. Caches the result in memory for 12h
+ /// (short TTL on transient failures). Admin endpoint can force-refresh.
+ ///
+ public interface ILatestVersionsService
+ {
+ Task GetAsync(bool forceRefresh, CancellationToken ct);
+ }
+
+ public sealed record LatestVersions(
+ string? AgentVersion,
+ string? BootstrapVersion,
+ string? AgentSha256,
+ DateTimeOffset FetchedAtUtc,
+ bool FromCache);
+
+ public sealed class LatestVersionsService : ILatestVersionsService
+ {
+ public const string VersionJsonUrl = "https://autopilotmonitor.blob.core.windows.net/agent/version.json";
+ private const string CacheKey = "latest-versions";
+ private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(12);
+ private static readonly TimeSpan FailureCacheDuration = TimeSpan.FromMinutes(5);
+
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IMemoryCache _cache;
+ private readonly ILogger _logger;
+
+ public LatestVersionsService(
+ IHttpClientFactory httpClientFactory,
+ IMemoryCache cache,
+ ILogger logger)
+ {
+ _httpClientFactory = httpClientFactory;
+ _cache = cache;
+ _logger = logger;
+ }
+
+ public async Task GetAsync(bool forceRefresh, CancellationToken ct)
+ {
+ if (!forceRefresh && _cache.TryGetValue(CacheKey, out LatestVersions? cached))
+ {
+ if (cached != null)
+ {
+ return cached with { FromCache = true };
+ }
+ // Cached "null" (transient failure marker) — return null without another blob call
+ return null;
+ }
+
+ try
+ {
+ using var client = _httpClientFactory.CreateClient();
+ client.Timeout = TimeSpan.FromSeconds(10);
+ using var response = await client.GetAsync(VersionJsonUrl, ct);
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadAsStringAsync(ct);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ string? version = root.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
+ string? bootstrapVersion = root.TryGetProperty("bootstrapVersion", out var bv) && bv.ValueKind == JsonValueKind.String ? bv.GetString() : null;
+ string? sha256 = root.TryGetProperty("sha256", out var sh) && sh.ValueKind == JsonValueKind.String ? sh.GetString() : null;
+
+ var result = new LatestVersions(
+ AgentVersion: version,
+ BootstrapVersion: bootstrapVersion,
+ AgentSha256: sha256,
+ FetchedAtUtc: DateTimeOffset.UtcNow,
+ FromCache: false);
+
+ _cache.Set(CacheKey, result, CacheDuration);
+ _logger.LogInformation("LatestVersions refreshed: agent={AgentVersion}, bootstrap={BootstrapVersion}", version, bootstrapVersion);
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to fetch {Url}; caching null for {Minutes}m", VersionJsonUrl, FailureCacheDuration.TotalMinutes);
+ // Cache failure briefly to avoid hammering blob on repeated calls
+ _cache.Set(CacheKey, (LatestVersions?)null, FailureCacheDuration);
+ return null;
+ }
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs
index 498c2a81..e94377ea 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs
@@ -250,6 +250,13 @@ namespace AutopilotMonitor.Shared.Models
///
public string LatestAgentExeSha256 { get; set; } = default!;
+ ///
+ /// Version string of the latest published bootstrap script (Install-AutopilotMonitor.ps1).
+ /// Written by CI/CD pipeline and PS build scripts after bootstrap script upload.
+ /// Used by the web UI to flag outdated bootstrap scripts in session views.
+ ///
+ public string LatestBootstrapScriptVersion { get; set; } = default!;
+
///
/// Whether vulnerability correlation is globally enabled.
/// When false, agents still collect inventory but backend skips correlation.
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx
index 264dfee9..72215fe2 100644
--- a/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx
@@ -17,6 +17,40 @@ export function MaintenanceTriggerSection({
}: MaintenanceTriggerSectionProps) {
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
const [maintenanceDate, setMaintenanceDate] = useState("");
+ const [refreshingVersions, setRefreshingVersions] = useState(false);
+
+ const handleRefreshLatestVersions = async () => {
+ try {
+ setRefreshingVersions(true);
+ setError(null);
+ setSuccessMessage(null);
+
+ const response = await authenticatedFetch(api.config.latestVersions({ refresh: true }), getAccessToken, {
+ method: "GET",
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to refresh latest versions: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ const agentVer = data.latestAgentVersion ?? "unknown";
+ const bootstrapVer = data.latestBootstrapScriptVersion ?? "unknown";
+ const fetchedAt = data.fetchedAtUtc ? new Date(data.fetchedAtUtc).toISOString().replace("T", " ").substring(0, 19) + " UTC" : "now";
+ setSuccessMessage(`Version cache refreshed: agent=${agentVer}, bootstrap=${bootstrapVer} (fetched ${fetchedAt})`);
+
+ setTimeout(() => setSuccessMessage(null), 8000);
+ } catch (err) {
+ if (err instanceof TokenExpiredError) {
+ console.error("Session expired while refreshing versions");
+ } else {
+ console.error("Error refreshing latest versions:", err);
+ }
+ setError(err instanceof Error ? err.message : "Failed to refresh latest versions");
+ } finally {
+ setRefreshingVersions(false);
+ }
+ };
const handleTriggerMaintenance = async () => {
try {
@@ -128,6 +162,38 @@ export function MaintenanceTriggerSection({
+
+
+
+
+ Refresh Latest Agent / Bootstrap Version Cache
+
+
+ Bypasses the 12h in-memory cache and re-fetches version.json from blob storage.
+ Use this immediately after publishing a new agent or bootstrap script build to see the new version in session badges.
+
+
+
+ {refreshingVersions ? (
+ <>
+
+ Reloading...
+ >
+ ) : (
+ <>
+
+
+
+ Reload Now
+ >
+ )}
+
+
+
-
-
-
-
-
-
Latest Agent Version
-
- {loading ? (
-
-
-
-
+
+
+
+
+
- Loading version info...
+
Latest Agent Version
- ) : versionInfo?.version ? (
-
-
- v{versionInfo.version}
-
-
- View Changelog
-
+ {loading ? (
+
+
+
+
+
+ Loading version info...
+
+ ) : versionInfo?.version ? (
+
+
+ v{versionInfo.version}
+
+
+ View Changelog
+
+
+ ) : (
+
Version information is currently unavailable.
+ )}
+
+
+
+
+
+
+
+
Latest Bootstrapper Script Version
- ) : (
-
Version information is currently unavailable.
- )}
+ {loading ? (
+
+
+
+
+
+ Loading version info...
+
+ ) : versionInfo?.bootstrapVersion ? (
+
+
+ v{versionInfo.bootstrapVersion}
+
+
+ ) : (
+
Version information is currently unavailable.
+ )}
+
diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx
index 9e242c9e..d5a973a8 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx
@@ -3,8 +3,9 @@
import { useState, useMemo } from "react";
import { EnrollmentEvent } from "@/types";
import OobeConfigModal from "./OobeConfigModal";
+import { compareVersions, stripGitHashSuffix } from "@/lib/bootstrapVersion";
-export default function DeviceDetailsCard({ events }: { events: EnrollmentEvent[] }) {
+export default function DeviceDetailsCard({ events, latestAgentVersion }: { events: EnrollmentEvent[]; latestAgentVersion?: string | null }) {
const [expanded, setExpanded] = useState(false);
const [showIpv6, setShowIpv6] = useState
>({});
const [showOobeModal, setShowOobeModal] = useState(false);
@@ -333,9 +334,27 @@ export default function DeviceDetailsCard({ events }: { events: EnrollmentEvent[
)}
{uptimeUntilEnrollment && }
- {agentStarted?.agentVersion && typeof agentStarted.agentVersion === 'string' && (
-
- )}
+ {agentStarted?.agentVersion && typeof agentStarted.agentVersion === 'string' && (() => {
+ const displayVersion = agentStarted.agentVersion.replace(/\+([0-9a-f]{7})[0-9a-f]+$/, '+$1');
+ const installedCleaned = stripGitHashSuffix(agentStarted.agentVersion);
+ const isOutdated = !!latestAgentVersion && compareVersions(installedCleaned, latestAgentVersion) < 0;
+ return (
+
+ Monitor Agent Version
+
+ {displayVersion}
+ {isOutdated && (
+
+ outdated
+
+ )}
+
+
+ );
+ })()}
{imeVersion && }
{(deviceLocation?.country || deviceLocation?.Country) && (
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 438574b3..577ef85e 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx
@@ -13,6 +13,7 @@ import PerformanceChart from '../../../components/PerformanceChart';
import DownloadProgress from '../../../components/DownloadProgress';
import InstallProgress from '../../../components/InstallProgress';
import ScriptExecutions from '../../../components/ScriptExecutions';
+import { useLatestVersions } from '@/lib/useLatestVersions';
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
@@ -80,6 +81,7 @@ export default function SessionDetailPage() {
const { tenantId } = useTenant();
const { getAccessToken, user } = useAuth();
const { addNotification } = useNotifications();
+ const { latestAgentVersion, latestBootstrapVersion } = useLatestVersions(getAccessToken);
const {
analysisResults,
@@ -962,7 +964,7 @@ export default function SessionDetailPage() {
)}
{/* Device Details Card (from enrollment tracker events) */}
- {!isGatherRulesSession &&
}
+ {!isGatherRulesSession &&
}
{/* Phase Timeline */}
{!isGatherRulesSession && session && (
@@ -1036,6 +1038,7 @@ export default function SessionDetailPage() {
e => e.eventType === "script_completed" || e.eventType === "script_failed"
)}
showScriptOutput={showScriptOutput}
+ latestBootstrapVersion={latestBootstrapVersion}
/>
)}
diff --git a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx
index 9374875d..eabeadba 100644
--- a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx
+++ b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
+import { compareVersions, extractBootstrapVersion } from "@/lib/bootstrapVersion";
interface ScriptEvent {
timestamp: string;
@@ -11,6 +12,7 @@ interface ScriptEvent {
interface ScriptExecutionsProps {
events: ScriptEvent[];
showScriptOutput?: boolean;
+ latestBootstrapVersion?: string | null;
}
interface ScriptItem {
@@ -26,9 +28,10 @@ interface ScriptItem {
state: "Success" | "Failed";
timestamp: string;
firstSeenIndex: number;
+ bootstrapVersion?: string | null; // Parsed from stdout if this is the Autopilot-Monitor bootstrap script
}
-export default function ScriptExecutions({ events, showScriptOutput }: ScriptExecutionsProps) {
+export default function ScriptExecutions({ events, showScriptOutput, latestBootstrapVersion }: ScriptExecutionsProps) {
const scripts = useMemo(() => {
if (events.length === 0) return [];
@@ -68,6 +71,7 @@ export default function ScriptExecutions({ events, showScriptOutput }: ScriptExe
state: isSuccess ? "Success" : "Failed",
timestamp: evt.timestamp,
firstSeenIndex: items.length,
+ bootstrapVersion: scriptType === "platform" ? extractBootstrapVersion(d.stdout) : null,
});
}
@@ -129,6 +133,7 @@ export default function ScriptExecutions({ events, showScriptOutput }: ScriptExe
key={`${item.policyId}-${item.scriptPart ?? ""}-${item.firstSeenIndex}`}
item={item}
showScriptOutput={showScriptOutput}
+ latestBootstrapVersion={latestBootstrapVersion}
/>
))}
@@ -144,7 +149,7 @@ function getIntuneScriptUrl(policyId: string, scriptType: string): string | null
return `https://intune.microsoft.com/#view/Microsoft_Intune_DeviceSettings/ConfigureWMPolicyMenuBlade/~/overview/policyId/${policyId}/policyType/0`;
}
-function ScriptItemRow({ item, showScriptOutput }: { item: ScriptItem; showScriptOutput?: boolean }) {
+function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion }: { item: ScriptItem; showScriptOutput?: boolean; latestBootstrapVersion?: string | null }) {
const [showDetails, setShowDetails] = useState(false);
const containerClass = item.state === "Failed"
@@ -200,6 +205,22 @@ function ScriptItemRow({ item, showScriptOutput }: { item: ScriptItem; showScrip
}`}>
{item.scriptType}
+ {item.bootstrapVersion && (
+
+ bootstrap v{item.bootstrapVersion}
+
+ )}
+ {item.bootstrapVersion && latestBootstrapVersion && compareVersions(item.bootstrapVersion, latestBootstrapVersion) < 0 && (
+
+ outdated
+
+ )}
{item.runContext && (
{item.runContext}
)}
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/bootstrapVersion.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/bootstrapVersion.test.ts
new file mode 100644
index 00000000..5f520a35
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/lib/__tests__/bootstrapVersion.test.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect } from "vitest";
+import {
+ BOOTSTRAP_VERSION_RE,
+ compareVersions,
+ extractBootstrapVersion,
+ stripGitHashSuffix,
+} from "../bootstrapVersion";
+
+describe("extractBootstrapVersion", () => {
+ it("extracts v1.1 from a single-line stdout", () => {
+ expect(extractBootstrapVersion("Bootstrap script version: v1.1")).toBe("1.1");
+ });
+
+ it("extracts v1.0.706 (3-segment)", () => {
+ expect(extractBootstrapVersion("Bootstrap script version: v1.0.706")).toBe("1.0.706");
+ });
+
+ it("extracts v1.2.3.4 (4-segment)", () => {
+ expect(extractBootstrapVersion("Bootstrap script version: v1.2.3.4")).toBe("1.2.3.4");
+ });
+
+ it("extracts from multi-line stdout", () => {
+ const stdout = [
+ "[2026-04-09 12:00:00] Starting bootstrap",
+ "[2026-04-09 12:00:01] Bootstrap script version: v1.1",
+ "[2026-04-09 12:00:02] Downloading agent...",
+ ].join("\n");
+ expect(extractBootstrapVersion(stdout)).toBe("1.1");
+ });
+
+ it("tolerates multiple spaces after colon", () => {
+ expect(extractBootstrapVersion("Bootstrap script version: v2.0")).toBe("2.0");
+ });
+
+ it("returns null when marker is absent", () => {
+ expect(extractBootstrapVersion("Hello World")).toBeNull();
+ });
+
+ it("returns null on empty / null / undefined input", () => {
+ expect(extractBootstrapVersion("")).toBeNull();
+ expect(extractBootstrapVersion(null)).toBeNull();
+ expect(extractBootstrapVersion(undefined)).toBeNull();
+ });
+
+ it("regex only matches our specific marker line, not arbitrary 'version: v' tokens", () => {
+ expect(BOOTSTRAP_VERSION_RE.test("some version: v1.0")).toBe(false);
+ expect(BOOTSTRAP_VERSION_RE.test("Script version: v1.0")).toBe(false);
+ });
+
+ it("does not match a v with no digits", () => {
+ expect(extractBootstrapVersion("Bootstrap script version: vX.Y")).toBeNull();
+ });
+});
+
+describe("compareVersions", () => {
+ it("equal versions return 0", () => {
+ expect(compareVersions("1.1", "1.1")).toBe(0);
+ expect(compareVersions("1.0.706", "1.0.706")).toBe(0);
+ });
+
+ it("pads missing segments with zero so 1.1 === 1.1.0", () => {
+ expect(compareVersions("1.1", "1.1.0")).toBe(0);
+ expect(compareVersions("1.1.0.0", "1.1")).toBe(0);
+ });
+
+ it("returns negative when a < b", () => {
+ expect(compareVersions("1.0", "1.1")).toBeLessThan(0);
+ expect(compareVersions("1.0.706", "1.0.707")).toBeLessThan(0);
+ expect(compareVersions("1.1", "2.0")).toBeLessThan(0);
+ });
+
+ it("returns positive when a > b", () => {
+ expect(compareVersions("1.1", "1.0")).toBeGreaterThan(0);
+ expect(compareVersions("2.0", "1.99.99")).toBeGreaterThan(0);
+ });
+
+ it("treats non-numeric segments as 0", () => {
+ expect(compareVersions("1.foo", "1.0")).toBe(0);
+ });
+});
+
+describe("stripGitHashSuffix", () => {
+ it("removes +hash suffix", () => {
+ expect(stripGitHashSuffix("1.0.706+abc1234")).toBe("1.0.706");
+ expect(stripGitHashSuffix("1.0.706+abc1234def5678")).toBe("1.0.706");
+ });
+
+ it("leaves clean versions untouched", () => {
+ expect(stripGitHashSuffix("1.0.706")).toBe("1.0.706");
+ expect(stripGitHashSuffix("1.1")).toBe("1.1");
+ });
+});
diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts
index b47db2ff..e51bce9c 100644
--- a/src/Web/autopilot-monitor-web/lib/api.ts
+++ b/src/Web/autopilot-monitor-web/lib/api.ts
@@ -62,6 +62,8 @@ export const api = {
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-failure`,
testNotification: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/test-notification`,
+ latestVersions: (opts?: { refresh?: boolean }) =>
+ `${API_BASE_URL}/api/config/latest-versions${qs({ refresh: opts?.refresh ? "true" : undefined })}`,
},
// ── Global Config (global admin) ──────────────────────────────────────────
diff --git a/src/Web/autopilot-monitor-web/lib/bootstrapVersion.ts b/src/Web/autopilot-monitor-web/lib/bootstrapVersion.ts
new file mode 100644
index 00000000..ea88f649
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/lib/bootstrapVersion.ts
@@ -0,0 +1,49 @@
+/**
+ * Helpers for detecting the Autopilot-Monitor bootstrap script version
+ * from captured Intune Platform Script stdout, and for comparing
+ * simple dotted version strings.
+ *
+ * The bootstrap script writes a deterministic marker line:
+ * "Bootstrap script version: v1.1"
+ * (see scripts/Bootstrap/Install-AutopilotMonitor.ps1)
+ */
+
+export const BOOTSTRAP_VERSION_RE = /Bootstrap script version:\s*v(\d+(?:\.\d+){1,3})/;
+
+/**
+ * Extracts the bootstrap script version from a Platform Script stdout blob.
+ * Returns null if the marker line is absent.
+ */
+export function extractBootstrapVersion(stdout?: string | null): string | null {
+ if (!stdout) return null;
+ const m = stdout.match(BOOTSTRAP_VERSION_RE);
+ return m ? m[1] : null;
+}
+
+/**
+ * Compares two dotted version strings (e.g. "1.0.706" vs "1.1").
+ * Segments are parsed as integers and missing segments are treated as 0,
+ * so "1.1" and "1.1.0" compare equal. Non-numeric segments are treated as 0
+ * (ignored) — this is intentional, we only care about "newer vs older" for badge purposes.
+ *
+ * @returns negative if a < b, 0 if equal, positive if a > b.
+ */
+export function compareVersions(a: string, b: string): number {
+ const pa = a.split(".").map(s => parseInt(s, 10) || 0);
+ const pb = b.split(".").map(s => parseInt(s, 10) || 0);
+ const len = Math.max(pa.length, pb.length);
+ for (let i = 0; i < len; i++) {
+ const av = pa[i] ?? 0;
+ const bv = pb[i] ?? 0;
+ if (av !== bv) return av - bv;
+ }
+ return 0;
+}
+
+/**
+ * Strips the +{gitHash} suffix from an agent version string (e.g. "1.0.706+abc1234" → "1.0.706").
+ * Leaves clean versions untouched.
+ */
+export function stripGitHashSuffix(version: string): string {
+ return version.replace(/\+.*$/, "");
+}
diff --git a/src/Web/autopilot-monitor-web/lib/useLatestVersions.ts b/src/Web/autopilot-monitor-web/lib/useLatestVersions.ts
new file mode 100644
index 00000000..d47d8bd0
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/lib/useLatestVersions.ts
@@ -0,0 +1,62 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { api } from "./api";
+import { authenticatedFetch } from "./authenticatedFetch";
+
+type GetAccessToken = (forceRefresh?: boolean) => Promise;
+
+export interface LatestVersionsResponse {
+ latestAgentVersion?: string | null;
+ latestBootstrapScriptVersion?: string | null;
+ latestAgentSha256?: string | null;
+ fetchedAtUtc?: string | null;
+ source?: "cache" | "blob" | null;
+}
+
+export interface UseLatestVersionsResult {
+ latestAgentVersion: string | null;
+ latestBootstrapVersion: string | null;
+ loading: boolean;
+}
+
+/**
+ * Fetches latest published agent/bootstrap versions from the backend
+ * once per mount. The backend caches the upstream blob for 12h, and
+ * the browser Cache-Control allows 1h of HTTP caching, so this is cheap.
+ *
+ * Silently swallows all errors — on failure, returns nulls so callers
+ * can gracefully hide "outdated" badges.
+ */
+export function useLatestVersions(getAccessToken: GetAccessToken): UseLatestVersionsResult {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ (async () => {
+ try {
+ const res = await authenticatedFetch(api.config.latestVersions(), getAccessToken, { method: "GET" });
+ if (!res.ok) return;
+ const json = (await res.json()) as LatestVersionsResponse;
+ if (!cancelled) setData(json);
+ } catch {
+ // swallow — badges just won't render
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+
+ return () => { cancelled = true; };
+ // getAccessToken identity typically stable from MSAL context; intentionally omit from deps
+ // to avoid refetching on every render.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return {
+ latestAgentVersion: data?.latestAgentVersion ?? null,
+ latestBootstrapVersion: data?.latestBootstrapScriptVersion ?? null,
+ loading,
+ };
+}