feat: allow agent downgrade

This commit is contained in:
Oliver Kieselbach
2026-04-14 17:51:50 +02:00
parent 6702e1d43b
commit 52fcfce271
14 changed files with 281 additions and 30 deletions
@@ -246,5 +246,63 @@ namespace AutopilotMonitor.Agent.Core.Tests.Core
Assert.NotNull(result.ParseError);
Assert.False(File.Exists(_paths.CheckedMarker)); // still cleaned up
}
private void WriteDowngradeBlocked(string current = "1.0.961", string latest = "1.0.951", string trigger = "runtime_hash_mismatch")
{
var json = new JObject
{
["outcome"] = "downgrade_blocked",
["reason"] = "downgrade_blocked",
["currentVersion"] = current,
["latestVersion"] = latest,
["triggerReason"] = trigger,
["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
["errorDetail"] = string.Empty,
};
File.WriteAllText(_paths.SkippedMarker, json.ToString());
}
[Fact]
public void DowngradeBlockedMarker_FirstTime_EmitsWarningEvent()
{
WriteDowngradeBlocked();
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
Assert.NotNull(result.Event);
Assert.Equal(EventSeverity.Warning, result.Event.Severity);
Assert.Equal("downgrade_blocked", result.Event.Data["outcome"]);
Assert.Equal("1.0.951", result.Event.Data["latestVersion"]);
Assert.Equal("1.0.961", result.Event.Data["currentVersion"]);
Assert.Equal("runtime_hash_mismatch", result.Event.Data["triggerReason"]);
Assert.Contains("downgrade from 1.0.961 to 1.0.951", result.Event.Message);
Assert.False(result.Deduped);
Assert.False(File.Exists(_paths.SkippedMarker));
}
[Fact]
public void DowngradeBlocked_SameSessionSameLatest_IsDeduped()
{
WriteLastEmit(SessionA, "1.0.951", "downgrade_blocked");
WriteDowngradeBlocked();
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
Assert.Null(result.Event);
Assert.True(result.Deduped);
Assert.Equal("downgrade_blocked", result.Outcome);
}
[Fact]
public void DowngradeBlocked_DifferentSession_EmitsFresh()
{
WriteLastEmit(SessionA, "1.0.951", "downgrade_blocked");
WriteDowngradeBlocked();
var result = VersionCheckEventBuilder.TryBuild(SessionB, TenantId, DateTime.UtcNow, _paths);
Assert.NotNull(result.Event);
Assert.False(result.Deduped);
}
}
}
@@ -78,7 +78,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
/// Receives the backend-provided ZIP SHA-256 hash so the updater can use it as the
/// trusted-channel integrity source.
/// </summary>
public Func<string, Task> RuntimeSelfUpdateTriggerAsync { get; set; }
/// <summary>
/// Host-provided runtime self-update trigger. Parameters: (backend ZIP hash, allowDowngrade).
/// The hash seeds <see cref="SelfUpdater"/>'s trusted-channel verification; the flag decides
/// whether a strictly older version advertised by the backend may replace the running binary.
/// </summary>
public Func<string, bool, Task> RuntimeSelfUpdateTriggerAsync { get; set; }
public MonitoringService(AgentConfiguration configuration, AgentLogger logger, string agentVersion,
string previousExitType = "first_run", string previousCrashException = null,
@@ -578,13 +583,15 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
// Hand the ZIP hash from the current config to the updater as trusted-channel source.
// Null is acceptable — SelfUpdater falls back to the version.json hash.
var zipHash = _remoteConfigService?.CurrentConfig?.LatestAgentSha256;
// Forward-only gate: admin must opt-in per tenant for a downgrade to apply.
var allowDowngrade = _remoteConfigService?.CurrentConfig?.AllowAgentDowngrade ?? false;
Task.Run(async () =>
{
try
{
_logger.Info("Self-update: triggering forced update due to runtime hash mismatch");
await RuntimeSelfUpdateTriggerAsync(zipHash).ConfigureAwait(false);
_logger.Info($"Self-update: triggering forced update due to runtime hash mismatch (allowDowngrade={allowDowngrade})");
await RuntimeSelfUpdateTriggerAsync(zipHash, allowDowngrade).ConfigureAwait(false);
_logger.Warning("Self-update: returned without restart — update did not apply, agent continues with stale binary");
}
catch (Exception ex)
@@ -75,8 +75,33 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
}
else if (File.Exists(paths.SkippedMarker))
{
result.Event = BuildSkippedEvent(paths.SkippedMarker, sessionId, tenantId, out var skipOutcome);
var skipEvent = BuildSkippedEvent(paths.SkippedMarker, sessionId, tenantId, out var skipOutcome);
result.Outcome = skipOutcome;
// Session-scoped dedup for downgrade_blocked: the runtime hash-mismatch path
// re-fires on every agent restart, and each restart would otherwise emit another
// Warning event for the same current→latest pair. Suppress duplicates within the
// same session when the same downgrade is advertised by the backend.
if (string.Equals(skipOutcome, "downgrade_blocked", StringComparison.Ordinal))
{
var latestVersion = ExtractLatestVersion(skipEvent);
var lastEmit = TryReadLastEmit(paths.LastEmit);
if (lastEmit != null &&
string.Equals(lastEmit.SessionId, sessionId, StringComparison.Ordinal) &&
string.Equals(lastEmit.LatestVersion, latestVersion, StringComparison.Ordinal) &&
string.Equals(lastEmit.Outcome, "downgrade_blocked", StringComparison.Ordinal))
{
result.Deduped = true;
}
else
{
result.Event = skipEvent;
}
}
else
{
result.Event = skipEvent;
}
}
else if (File.Exists(paths.CheckedMarker))
{
@@ -189,6 +214,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
var latestVersion = (string)marker["latestVersion"];
var skippedAtUtc = (string)marker["skippedAtUtc"] ?? "unknown";
var errorDetail = (string)marker["errorDetail"] ?? string.Empty;
var triggerReason = (string)marker["triggerReason"];
// Marker may already carry outcome (new SelfUpdater). For forward-compat with old markers
// lying around across an upgrade, derive it from the reason.
@@ -205,6 +231,16 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
};
if (latestVersion != null)
data["latestVersion"] = latestVersion;
if (!string.IsNullOrEmpty(triggerReason))
data["triggerReason"] = triggerReason;
string message;
if (outcome == "check_failed")
message = $"Agent version check failed (reason={reason})";
else if (outcome == "downgrade_blocked")
message = $"Agent self-update blocked: downgrade from {currentVersion} to {latestVersion ?? "unknown"} (trigger={triggerReason ?? "unknown"}, allowDowngrade=false)";
else
message = $"Agent self-update skipped (reason={reason})";
return new EnrollmentEvent
{
@@ -213,9 +249,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
EventType = Constants.EventTypes.AgentVersionCheck,
Severity = EventSeverity.Warning,
Source = "Agent",
Message = outcome == "check_failed"
? $"Agent version check failed (reason={reason})"
: $"Agent self-update skipped (reason={reason})",
Message = message,
Data = data,
ImmediateUpload = true
};
+13 -3
View File
@@ -101,6 +101,7 @@ namespace AutopilotMonitor.Agent
// Try to load backend hash from cached remote-config.json (from a previous run).
// This gives the self-updater a second trust channel for integrity verification.
bool cachedAllowAgentDowngrade = false;
try
{
var cachedConfigPath = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config\remote-config.json");
@@ -117,6 +118,10 @@ namespace AutopilotMonitor.Agent
{
SelfUpdater.Log("Self-update: no backend integrity hash in cached config — will use version.json hash as fallback");
}
cachedAllowAgentDowngrade = cachedConfig?.AllowAgentDowngrade ?? false;
if (cachedAllowAgentDowngrade)
SelfUpdater.Log("Self-update: cached config has AllowAgentDowngrade=true (downgrades permitted)");
}
else
{
@@ -128,7 +133,11 @@ namespace AutopilotMonitor.Agent
SelfUpdater.Log($"Self-update: could not load cached config for backend hash: {ex.Message}");
}
SelfUpdater.CheckAndApplyUpdateAsync(GetAgentVersion(), agentDir, consoleMode).GetAwaiter().GetResult();
SelfUpdater.CheckAndApplyUpdateAsync(
GetAgentVersion(),
agentDir,
consoleMode,
allowDowngrade: cachedAllowAgentDowngrade).GetAwaiter().GetResult();
// If we reach here, no update was applied — continue normal startup
try
@@ -329,7 +338,7 @@ namespace AutopilotMonitor.Agent
// invokes SelfUpdater with forceUpdate=true and a longer download timeout
// (60s, vs. 10s at startup) because monitoring is already running and we
// know an update is required.
service.RuntimeSelfUpdateTriggerAsync = zipHash =>
service.RuntimeSelfUpdateTriggerAsync = (zipHash, allowDowngrade) =>
{
if (!string.IsNullOrEmpty(zipHash))
SelfUpdater.BackendExpectedSha256 = zipHash;
@@ -340,7 +349,8 @@ namespace AutopilotMonitor.Agent
consoleMode: consoleMode,
forceUpdate: true,
triggerReason: "runtime_hash_mismatch",
downloadTimeoutMsOverride: 60000);
downloadTimeoutMsOverride: 60000,
allowDowngrade: allowDowngrade);
};
service.Start();
+107 -17
View File
@@ -104,13 +104,20 @@ namespace AutopilotMonitor.Agent
/// Autopilot boot speed. Runtime trigger passes 60000 because monitoring is already running
/// and we know an update is required.
/// </param>
/// <param name="allowDowngrade">
/// When false (default), a latest version strictly lower than <paramref name="currentVersion"/>
/// is rejected even when <paramref name="forceUpdate"/> is true — prevents the production
/// <c>version.json</c> from silently overwriting a higher-versioned dev/pre-release agent via
/// the runtime hash-mismatch path. Set to true only for controlled rollback scenarios.
/// </param>
public static async Task CheckAndApplyUpdateAsync(
string currentVersion,
string agentDir,
bool consoleMode,
bool forceUpdate = false,
string triggerReason = "startup",
int? downloadTimeoutMsOverride = null)
int? downloadTimeoutMsOverride = null,
bool allowDowngrade = false)
{
Action<string> log = msg =>
{
@@ -141,19 +148,54 @@ namespace AutopilotMonitor.Agent
return; // Could not determine latest version — continue with current
}
// Step 2: Compare versions (skipped when forceUpdate=true)
if (!forceUpdate && !IsNewerVersion(currentVersion, latestVersion))
{
log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
if (isStartupTrigger)
WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log);
return;
}
// Step 2: Version comparison. Three-way decision:
// cmp < 0 → latest < current = downgrade
// cmp == 0 → latest == current = same version (hash repair only, requires forceUpdate)
// cmp > 0 → latest > current = upgrade
var cmp = CompareVersions(currentVersion, latestVersion);
if (forceUpdate)
log($"Self-update: FORCE mode (trigger={triggerReason}) — current={currentVersion}, latest={latestVersion}, skipping version check");
else
if (cmp < 0)
{
// Downgrade — forward-only policy blocks this by default even when forceUpdate=true.
// Protects dev/pre-release builds from being silently replaced by the production
// version advertised by the prod backend (runtime_hash_mismatch force path).
if (!allowDowngrade)
{
log($"Self-update: downgrade BLOCKED — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}, allowDowngrade=false");
WriteDowngradeBlockedMarker(currentVersion, latestVersion, triggerReason, log);
return;
}
log($"Self-update: downgrade ALLOWED (admin override) — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}");
}
else if (cmp == 0)
{
// Same version: only proceed when forceUpdate (e.g. runtime hash mismatch = binary repair).
if (!forceUpdate)
{
log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
if (isStartupTrigger)
WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log);
return;
}
log($"Self-update: same-version REPAIR (trigger={triggerReason}) — reinstalling {currentVersion}");
}
else if (cmp > 0)
{
log($"Self-update: newer version available — current={currentVersion}, latest={latestVersion}");
}
else
{
// cmp == int.MinValue sentinel from CompareVersions → unparsable version string on
// either side. Fall back to the pre-fix behaviour: proceed only when forceUpdate.
if (!forceUpdate)
{
log($"Self-update: cannot compare versions (current={currentVersion}, latest={latestVersion}) — skipping");
if (isStartupTrigger)
WriteSkipMarker("version_compare_failed", currentVersion, latestVersion, "version parse failed", log);
return;
}
log($"Self-update: FORCE mode (trigger={triggerReason}) — current={currentVersion}, latest={latestVersion}, proceeding despite unparsable versions");
}
// Step 3: Download ZIP (timeout = downloadTimeoutMs)
var zipPath = Path.Combine(Path.GetTempPath(), "AutopilotMonitor-Agent-Update.zip");
@@ -361,12 +403,25 @@ namespace AutopilotMonitor.Agent
/// </summary>
private static bool IsNewerVersion(string current, string latest)
{
if (!Version.TryParse(StripVersionSuffix(current), out var currentVer))
return false;
if (!Version.TryParse(StripVersionSuffix(latest), out var latestVer))
return false;
return CompareVersions(current, latest) > 0;
}
return latestVer > currentVer;
/// <summary>
/// Compares two version strings. Returns negative when latest is older than current,
/// zero when equal, positive when latest is newer than current. Returns
/// <see cref="int.MinValue"/> when either side is not a parseable <see cref="Version"/>
/// (callers must treat this as "cannot compare" rather than an ordered result).
/// Strips SemVer suffixes (+metadata, -prerelease) before parsing because
/// System.Version cannot handle them.
/// </summary>
internal static int CompareVersions(string current, string latest)
{
if (!Version.TryParse(StripVersionSuffix(current), out var currentVer))
return int.MinValue;
if (!Version.TryParse(StripVersionSuffix(latest), out var latestVer))
return int.MinValue;
return latestVer.CompareTo(currentVer);
}
private static string StripVersionSuffix(string version)
@@ -650,6 +705,41 @@ namespace AutopilotMonitor.Agent
}
}
/// <summary>
/// Writes a downgrade-blocked marker so the next agent startup can emit an
/// <c>agent_version_check</c> event with <c>outcome=downgrade_blocked</c>. Uses the
/// existing skipped-marker channel so <see cref="Core.Monitoring.Core.VersionCheckEventBuilder"/>
/// picks it up alongside other skip reasons. Best-effort — never throws.
/// </summary>
private static void WriteDowngradeBlockedMarker(string currentVersion, string latestVersion, string triggerReason, Action<string> log)
{
try
{
var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile);
var dir = Path.GetDirectoryName(markerPath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
var payload = new JObject
{
["outcome"] = "downgrade_blocked",
["reason"] = "downgrade_blocked",
["currentVersion"] = currentVersion ?? "unknown",
["latestVersion"] = latestVersion ?? "unknown",
["triggerReason"] = triggerReason ?? "unknown",
["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
["errorDetail"] = string.Empty
};
File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
log($"Self-update: downgrade-blocked marker written (current={currentVersion}, latest={latestVersion}, trigger={triggerReason})");
}
catch (Exception ex)
{
log($"Self-update: could not write downgrade-blocked marker — {ex.Message} (non-critical)");
}
}
/// <summary>
/// Writes a small JSON marker on the happy path (current version is already up to date) so
/// the next agent startup can emit an agent_version_check event with outcome=up_to_date.
@@ -1 +1 @@
960
963
@@ -555,6 +555,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "LatestAgentVersion", config.LatestAgentVersion ?? string.Empty },
{ "LatestAgentSha256", config.LatestAgentSha256 ?? string.Empty },
{ "LatestAgentExeSha256", config.LatestAgentExeSha256 ?? string.Empty },
{ "AllowAgentDowngrade", config.AllowAgentDowngrade },
{ "LatestBootstrapScriptVersion", config.LatestBootstrapScriptVersion ?? string.Empty },
// Rate limiting per-role settings
{ "UserRateLimitRequestsPerMinute", config.UserRateLimitRequestsPerMinute },
@@ -604,6 +605,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
LatestAgentVersion = entity.GetString("LatestAgentVersion") ?? string.Empty,
LatestAgentSha256 = entity.GetString("LatestAgentSha256") ?? string.Empty,
LatestAgentExeSha256 = entity.GetString("LatestAgentExeSha256") ?? string.Empty,
AllowAgentDowngrade = entity.GetBoolean("AllowAgentDowngrade") ?? false,
LatestBootstrapScriptVersion = entity.GetString("LatestBootstrapScriptVersion") ?? string.Empty,
// Rate limiting per-role settings
UserRateLimitRequestsPerMinute = entity.GetInt32("UserRateLimitRequestsPerMinute") ?? 120,
@@ -136,7 +136,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new AgentConfigResponse
{
ConfigVersion = 22, // StallProbe trace indices [2,3,4] + ModernDeployment EventID 100 downgrade
ConfigVersion = 23, // AllowAgentDowngrade forward-only self-update gate
UploadIntervalSeconds = 10,
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
KeepLogFile = tenantConfig.KeepLogFile ?? false,
@@ -164,6 +164,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
},
LatestAgentSha256 = adminConfig.LatestAgentSha256,
LatestAgentExeSha256 = adminConfig.LatestAgentExeSha256,
AllowAgentDowngrade = adminConfig.AllowAgentDowngrade,
NtpServer = string.IsNullOrEmpty(tenantConfig.NtpServer) ? "time.windows.com" : tenantConfig.NtpServer,
EnableTimezoneAutoSet = tenantConfig.EnableTimezoneAutoSet ?? false,
SendTraceEvents = tenantConfig.SendTraceEvents,
@@ -250,6 +250,15 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string LatestAgentExeSha256 { get; set; } = default!;
/// <summary>
/// When true, the agent's self-updater is allowed to install a version strictly lower
/// than the one it is currently running. Default: false (forward-only updates; prevents
/// dev builds from being silently downgraded to the production <c>version.json</c> via
/// the <c>runtime_hash_mismatch</c> force path). Set to true only for controlled rollback
/// scenarios — flip back to false immediately afterwards.
/// </summary>
public bool AllowAgentDowngrade { get; set; } = false;
/// <summary>
/// Version string of the latest published bootstrap script (Install-AutopilotMonitor.ps1).
/// Written by CI/CD pipeline and PS build scripts after bootstrap script upload.
@@ -176,6 +176,14 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string LatestAgentExeSha256 { get; set; } = default!;
/// <summary>
/// When true, the self-updater will install a lower version if the backend advertises one.
/// Default: false (forward-only). Gated per tenant via the admin configuration.
/// Only honoured by the runtime force-update path (hash mismatch) — startup version
/// comparison never downgrades regardless of this flag.
/// </summary>
public bool AllowAgentDowngrade { get; set; } = false;
/// <summary>
/// NTP server address for time check during enrollment.
/// Default: "time.windows.com"
@@ -30,6 +30,8 @@ interface AdminConfigContextValue {
setMaintenanceBlockDurationHours: (value: number) => void;
opsEventRetentionDays: number;
setOpsEventRetentionDays: (value: number) => void;
allowAgentDowngrade: boolean;
setAllowAgentDowngrade: (value: boolean) => void;
// Diagnostics log paths
globalDiagPaths: DiagnosticsLogPath[];
@@ -101,6 +103,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const [maxSessionWindowHours, setMaxSessionWindowHours] = useState(24);
const [maintenanceBlockDurationHours, setMaintenanceBlockDurationHours] = useState(12);
const [opsEventRetentionDays, setOpsEventRetentionDays] = useState(90);
const [allowAgentDowngrade, setAllowAgentDowngrade] = useState(false);
// Diagnostics Log Paths state
const [globalDiagPaths, setGlobalDiagPaths] = useState<DiagnosticsLogPath[]>([]);
@@ -150,6 +153,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setMaxSessionWindowHours(data.maxSessionWindowHours ?? 24);
setMaintenanceBlockDurationHours(data.maintenanceBlockDurationHours ?? 12);
setOpsEventRetentionDays(data.opsEventRetentionDays ?? 90);
setAllowAgentDowngrade(data.allowAgentDowngrade ?? false);
try {
setGlobalDiagPaths(data.diagnosticsGlobalLogPathsJson ? JSON.parse(data.diagnosticsGlobalLogPathsJson) : []);
} catch {
@@ -240,6 +244,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
maxSessionWindowHours,
maintenanceBlockDurationHours,
opsEventRetentionDays,
allowAgentDowngrade,
};
const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, {
@@ -266,7 +271,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
} finally {
setSavingConfig(false);
}
}, [adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, getAccessToken]);
}, [adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, allowAgentDowngrade, getAccessToken]);
// Reset admin config
const handleResetAdminConfig = useCallback(() => {
@@ -277,6 +282,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setMaxSessionWindowHours(adminConfig.maxSessionWindowHours ?? 24);
setMaintenanceBlockDurationHours(adminConfig.maintenanceBlockDurationHours ?? 12);
setOpsEventRetentionDays(adminConfig.opsEventRetentionDays ?? 90);
setAllowAgentDowngrade(adminConfig.allowAgentDowngrade ?? false);
try {
setGlobalDiagPaths(adminConfig.diagnosticsGlobalLogPathsJson ? JSON.parse(adminConfig.diagnosticsGlobalLogPathsJson) : []);
} catch {
@@ -395,6 +401,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
maxSessionWindowHours, setMaxSessionWindowHours,
maintenanceBlockDurationHours, setMaintenanceBlockDurationHours,
opsEventRetentionDays, setOpsEventRetentionDays,
allowAgentDowngrade, setAllowAgentDowngrade,
globalDiagPaths, setGlobalDiagPaths, savingDiagPaths,
opsAlertRules, setOpsAlertRules,
opsAlertTelegramEnabled, setOpsAlertTelegramEnabled,
@@ -12,6 +12,8 @@ interface AdminConfigSettingsSectionProps {
setPlatformStatsBlobSasUrl: (value: string) => void;
collectorIdleTimeoutMinutes: number;
setCollectorIdleTimeoutMinutes: (value: number) => void;
allowAgentDowngrade: boolean;
setAllowAgentDowngrade: (value: boolean) => void;
onSave: () => Promise<void>;
onReset: () => void;
}
@@ -26,6 +28,8 @@ export function AdminConfigSettingsSection({
setPlatformStatsBlobSasUrl,
collectorIdleTimeoutMinutes,
setCollectorIdleTimeoutMinutes,
allowAgentDowngrade,
setAllowAgentDowngrade,
onSave,
onReset,
}: AdminConfigSettingsSectionProps) {
@@ -108,6 +112,23 @@ export function AdminConfigSettingsSection({
</label>
</div>
<div>
<label className="flex items-start space-x-3 cursor-pointer">
<input
type="checkbox"
checked={allowAgentDowngrade}
onChange={(e) => setAllowAgentDowngrade(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 focus:ring-indigo-500"
/>
<span>
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Allow agent downgrade</span>
<p className="text-sm text-indigo-800 dark:text-gray-300 mt-1">
When <strong>off</strong> (default), the agent&apos;s self-updater refuses to install a version strictly lower than the one it is currently running — including via the runtime hash-mismatch force path. Protects dev/pre-release builds from being replaced by the production <code className="text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">version.json</code>. Turn on only for controlled rollback scenarios, then turn off again.
</p>
</span>
</label>
</div>
{adminConfig && (
<div className="bg-blue-50 dark:bg-gray-700 border border-blue-200 dark:border-indigo-600 rounded-lg p-3">
<div className="flex items-start space-x-2">
@@ -10,6 +10,7 @@ export function SectionGlobalSettings() {
globalRateLimit, setGlobalRateLimit,
platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl,
collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes,
allowAgentDowngrade, setAllowAgentDowngrade,
handleSaveAdminConfig, handleResetAdminConfig,
} = useAdminConfig();
@@ -26,6 +27,8 @@ export function SectionGlobalSettings() {
setPlatformStatsBlobSasUrl={setPlatformStatsBlobSasUrl}
collectorIdleTimeoutMinutes={collectorIdleTimeoutMinutes}
setCollectorIdleTimeoutMinutes={setCollectorIdleTimeoutMinutes}
allowAgentDowngrade={allowAgentDowngrade}
setAllowAgentDowngrade={setAllowAgentDowngrade}
onSave={handleSaveAdminConfig}
onReset={handleResetAdminConfig}
/>
@@ -21,6 +21,7 @@ export interface AdminConfiguration {
opsAlertTeamsWebhookUrl?: string;
opsAlertSlackEnabled?: boolean;
opsAlertSlackWebhookUrl?: string;
allowAgentDowngrade?: boolean;
}
export interface OpsAlertRule {