From 74411d3d816062aa6d3647c67e287de6d9c70908 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Wed, 11 Mar 2026 15:44:55 -0700 Subject: [PATCH 1/3] feat: add urgent notice system for CLI security and upgrade announcements Introduces a server-driven notice system that displays urgent messages (security advisories, critical upgrade prompts) to users on every CLI invocation, with 4-hour TTL local caching to avoid unnecessary network calls. Notices are suppressed once the user upgrades past the specified minimumVersion. Extracted shared CI/CD detection and version parsing into VersionCheckHelper. Both notice and version checks run with independent 2-second timeouts. Co-Authored-By: Claude Sonnet 4.6 --- notices.json | 5 + .../Models/NoticeModels.cs | 23 ++ .../Models/VersionCheckModels.cs | 5 + .../Program.cs | 38 ++- .../Services/INoticeService.cs | 20 ++ .../Services/IVersionCheckService.cs | 2 +- .../Services/Internal/VersionCheckHelper.cs | 118 ++++++++++ .../Services/NoticeService.cs | 173 ++++++++++++++ .../Services/VersionCheckService.cs | 221 +++++------------- .../Services/NoticeServiceTests.cs | 169 ++++++++++++++ .../Services/VersionCheckServiceTests.cs | 7 +- 11 files changed, 615 insertions(+), 166 deletions(-) create mode 100644 notices.json create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Models/NoticeModels.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/INoticeService.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/VersionCheckHelper.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/NoticeService.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/NoticeServiceTests.cs diff --git a/notices.json b/notices.json new file mode 100644 index 00000000..000eea5c --- /dev/null +++ b/notices.json @@ -0,0 +1,5 @@ +{ + "message": "", + "minimumVersion": null, + "expiresAt": "2000-01-01T00:00:00Z" +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/NoticeModels.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/NoticeModels.cs new file mode 100644 index 00000000..ee3c554c --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/NoticeModels.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// Notice fetched from the server-side notices endpoint. +/// All fields are nullable — an empty or partial response means no active notice. +/// +public record Notice( + string? Message, + string? MinimumVersion, + DateTimeOffset? ExpiresAt); + +/// +/// Result of a notice check — what the caller acts on. +/// +public record NoticeResult(bool HasNotice, string? Message, string? UpdateCommand); + +/// +/// On-disk cache envelope for the notice, keyed by fetch timestamp. +/// +public record NoticeCache(DateTimeOffset CachedAt, Notice? ActiveNotice); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/VersionCheckModels.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/VersionCheckModels.cs index 86cb6676..e5be9467 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/VersionCheckModels.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/VersionCheckModels.cs @@ -16,3 +16,8 @@ public record VersionCheckResult( string? CurrentVersion, string? LatestVersion, string? UpdateCommand); + +/// +/// On-disk cache envelope for the version check result, keyed by fetch timestamp. +/// +public record VersionCheckCache(DateTimeOffset CachedAt, string? LatestVersion); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 3bfe0e49..ddb9a8ac 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -48,13 +48,42 @@ static async Task Main(string[] args) ConfigureServices(services, logLevel, logFilePath); var serviceProvider = services.BuildServiceProvider(); - // Check for updates (non-blocking, with timeout) + // Notice check — runs first, independent timeout so a slow network call + // cannot starve the version check. try { - var versionCheckService = serviceProvider.GetRequiredService(); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - var result = await versionCheckService.CheckForUpdatesAsync(cts.Token); + var noticeService = serviceProvider.GetRequiredService(); + using var noticeCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var noticeResult = await noticeService.CheckForNoticeAsync(noticeCts.Token); + if (noticeResult.HasNotice) + { + var separator = new string('-', 60); + startupLogger.LogWarning(""); + startupLogger.LogWarning(separator); + startupLogger.LogWarning("URGENT NOTICE"); + startupLogger.LogWarning(separator); + startupLogger.LogWarning("{Message}", noticeResult.Message); + startupLogger.LogWarning(""); + startupLogger.LogWarning("To update, run: {Command}", noticeResult.UpdateCommand); + startupLogger.LogWarning(separator); + startupLogger.LogWarning(""); + } + } + catch (OperationCanceledException) + { + startupLogger.LogDebug("Notice check timed out"); + } + catch (Exception ex) + { + startupLogger.LogDebug(ex, "Notice check failed: {Message}", ex.Message); + } + // Version update check — independent timeout + try + { + var versionCheckService = serviceProvider.GetRequiredService(); + using var versionCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var result = await versionCheckService.CheckForUpdatesAsync(versionCts.Token); if (result.UpdateAvailable) { startupLogger.LogWarning(""); @@ -194,6 +223,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Add Microsoft Agent 365 Tooling Service with environment detection services.AddSingleton(provider => diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/INoticeService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/INoticeService.cs new file mode 100644 index 00000000..e7a92fba --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/INoticeService.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Models; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Service for checking whether an active notice should be shown to the user. +/// +public interface INoticeService +{ + /// + /// Checks for an active notice from the server-side notices endpoint. + /// Results are cached locally with a TTL to avoid a network call on every invocation. + /// + /// Cancellation token to abort the check. + /// Result indicating whether there is an active notice to display. + Task CheckForNoticeAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVersionCheckService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVersionCheckService.cs index 9316b058..4bb16dc8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVersionCheckService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVersionCheckService.cs @@ -6,7 +6,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// -/// Service for checking if newer versions of the CLI are available. +/// Service for checking if a newer version of the CLI is available on NuGet. /// public interface IVersionCheckService { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/VersionCheckHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/VersionCheckHelper.cs new file mode 100644 index 00000000..caaddd89 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/VersionCheckHelper.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Services.Internal; + +/// +/// Shared helpers used by both and +/// . Internal to the assembly. +/// +internal static class VersionCheckHelper +{ + private const string PackageId = "Microsoft.Agents.A365.DevTools.Cli"; + + /// + /// Returns true if the current process is running inside a known CI/CD environment. + /// Both version and notice checks are skipped in CI to avoid unnecessary network calls. + /// + internal static bool IsRunningInCiCd() + { + var ciEnvVars = new[] + { + "CI", // Generic CI indicator + "TF_BUILD", // Azure DevOps + "GITHUB_ACTIONS", // GitHub Actions + "JENKINS_HOME", // Jenkins + "GITLAB_CI", // GitLab CI + "CIRCLECI", // CircleCI + "TRAVIS", // Travis CI + "TEAMCITY_VERSION", // TeamCity + "BUILDKITE", // Buildkite + "CODEBUILD_BUILD_ID" // AWS CodeBuild + }; + + return ciEnvVars.Any(envVar => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envVar))); + } + + /// + /// Parses a semantic version string into a comparable . + /// Handles formats such as "1.1.52", "1.1.0-preview.50", and "1.1.52-preview". + /// Throws if parsing fails. + /// + internal static Version ParseVersion(string versionString) + { + var parsed = TryParseVersion(versionString); + if (parsed == null) + throw new FormatException($"Invalid version format: {versionString}"); + return parsed; + } + + /// + /// Tries to parse a semantic version string. Returns null on failure. + /// + /// Supported formats: + /// - "1.1.52-preview" (iteration in base version number) + /// - "1.1.0-preview.50" (preview number is a separate segment) + /// + internal static Version? TryParseVersion(string versionString) + { + try + { + // Remove build metadata (+...) + var cleanVersion = versionString.Split('+')[0]; + + if (cleanVersion.Contains('-')) + { + var parts = cleanVersion.Split('-'); + var baseVersion = parts[0]; // e.g., "1.1.52" or "1.1.0" + + if (parts.Length > 1) + { + var previewPart = parts[1]; // e.g., "preview" or "preview.50" + + // Format: "1.1.0-preview.50" — append preview number as revision + if (previewPart.StartsWith("preview.") && previewPart.Length > 8) + { + var previewNumber = previewPart.Substring(8); + cleanVersion = int.TryParse(previewNumber, out var preview) + ? $"{baseVersion}.{preview}" + : baseVersion; + } + else + { + // Format: "1.1.52-preview" — iteration is already in the base number + cleanVersion = baseVersion; + } + } + else + { + cleanVersion = baseVersion; + } + } + + // Ensure at least 3 components for the Version constructor + var versionParts = cleanVersion.Split('.'); + var componentsNeeded = 3 - versionParts.Length; + for (var i = 0; i < componentsNeeded; i++) + cleanVersion += ".0"; + + return new Version(cleanVersion); + } + catch + { + return null; + } + } + + /// + /// Returns the appropriate dotnet tool update command for the given version string. + /// Appends --prerelease when the version is a preview build. + /// + internal static string GetUpdateCommand(string version) + { + var baseCommand = $"dotnet tool update -g {PackageId}"; + return version.Contains("preview", StringComparison.OrdinalIgnoreCase) + ? $"{baseCommand} --prerelease" + : baseCommand; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/NoticeService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/NoticeService.cs new file mode 100644 index 00000000..3a9fdcea --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/NoticeService.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Checks for an active notice posted to the server-side notices endpoint. +/// Results are cached locally for hours to avoid a network +/// call on every CLI invocation. +/// +public class NoticeService : INoticeService +{ + private const string NoticesUrl = "https://raw.githubusercontent.com/microsoft/Agent365-devTools/main/notices.json"; + private const string CacheFileName = "notice.cache.json"; + private const int CacheTtlHours = 4; + + private readonly ILogger _logger; + private readonly string _currentVersion; + + public NoticeService(ILogger logger) + { + _logger = logger; + _currentVersion = Program.GetDisplayVersion(); + } + + /// + public async Task CheckForNoticeAsync(CancellationToken cancellationToken = default) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + + if (VersionCheckHelper.IsRunningInCiCd()) + { + _logger.LogDebug("Skipping notice check in CI/CD environment"); + return new NoticeResult(false, null, null); + } + + var notice = await GetNoticeWithCacheAsync(cancellationToken); + + if (notice == null || string.IsNullOrWhiteSpace(notice.Message)) + return new NoticeResult(false, null, null); + + if (notice.ExpiresAt.HasValue && notice.ExpiresAt.Value <= DateTimeOffset.UtcNow) + { + _logger.LogDebug("Notice expired at {ExpiresAt}", notice.ExpiresAt); + return new NoticeResult(false, null, null); + } + + // If the user is already on a version that meets the minimum, suppress the notice + if (!string.IsNullOrWhiteSpace(notice.MinimumVersion)) + { + try + { + var current = VersionCheckHelper.ParseVersion(_currentVersion); + var minimum = VersionCheckHelper.ParseVersion(notice.MinimumVersion); + if (current >= minimum) + { + _logger.LogDebug("Current version {Current} meets minimum {Minimum}; notice suppressed", _currentVersion, notice.MinimumVersion); + return new NoticeResult(false, null, null); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Version comparison failed; showing notice as a precaution"); + } + } + + // Use minimumVersion to determine --prerelease flag when specified; + // fall back to current version (e.g. notice has no minimumVersion). + var versionForCommand = string.IsNullOrWhiteSpace(notice.MinimumVersion) + ? _currentVersion + : notice.MinimumVersion; + var updateCommand = VersionCheckHelper.GetUpdateCommand(versionForCommand); + + return new NoticeResult(true, notice.Message, updateCommand); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Notice check cancelled"); + throw; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Notice check failed: {Message}", ex.Message); + return new NoticeResult(false, null, null); + } + } + + private async Task GetNoticeWithCacheAsync(CancellationToken cancellationToken) + { + var cacheFilePath = GetCacheFilePath(); + var cached = TryLoadCache(cacheFilePath); + + if (cached != null && DateTimeOffset.UtcNow - cached.CachedAt < TimeSpan.FromHours(CacheTtlHours)) + { + _logger.LogDebug("Using cached notice (cached at {CachedAt})", cached.CachedAt); + return cached.ActiveNotice; + } + + var notice = await FetchFromServerAsync(cancellationToken); + SaveCache(cacheFilePath, new NoticeCache(DateTimeOffset.UtcNow, notice)); + return notice; + } + + private async Task FetchFromServerAsync(CancellationToken cancellationToken) + { + try + { + using var httpClient = HttpClientFactory.CreateAuthenticatedClient(authToken: null); + using var response = await httpClient.GetAsync(NoticesUrl, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogDebug("Notices endpoint returned {StatusCode}", response.StatusCode); + return null; + } + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + return JsonSerializer.Deserialize(content, options); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug(ex, "Failed to fetch notices from server"); + return null; + } + } + + private NoticeCache? TryLoadCache(string path) + { + try + { + if (!File.Exists(path)) + return null; + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + return JsonSerializer.Deserialize(File.ReadAllText(path), options); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not load notice cache from {Path}", path); + return null; + } + } + + private void SaveCache(string path, NoticeCache cache) + { + try + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + File.WriteAllText(path, JsonSerializer.Serialize(cache)); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not save notice cache to {Path}", path); + } + } + + /// + /// Returns the path to the on-disk notice cache file. + /// + internal static string GetCacheFilePath() + => Path.Combine(ConfigService.GetGlobalConfigDirectory(), CacheFileName); +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs index 78b88006..d90d6305 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs @@ -9,12 +9,15 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// -/// Service for checking if newer versions of the CLI are available on NuGet. +/// Checks NuGet for a newer version of the CLI and returns an update prompt when one is available. +/// Results are cached locally for hours to avoid a NuGet API call +/// on every invocation. /// public class VersionCheckService : IVersionCheckService { private const string NuGetApiUrl = "https://api.nuget.org/v3-flatcontainer/microsoft.agents.a365.devtools.cli/index.json"; - private const string PackageId = "Microsoft.Agents.A365.DevTools.Cli"; + private const string CacheFileName = "version.cache.json"; + private const int CacheTtlHours = 24; private readonly ILogger _logger; private readonly string _currentVersion; @@ -30,17 +33,23 @@ public async Task CheckForUpdatesAsync(CancellationToken can { try { - // Skip check in CI/CD environments - if (IsRunningInCiCd()) + cancellationToken.ThrowIfCancellationRequested(); + + if (VersionCheckHelper.IsRunningInCiCd()) { _logger.LogDebug("Skipping version check in CI/CD environment"); return new VersionCheckResult(false, _currentVersion, null, null); } - _logger.LogDebug("Checking for updates from NuGet..."); + _logger.LogDebug("Checking for updates..."); - // Query NuGet API for available versions - var latestVersion = await GetLatestVersionFromNuGetAsync(cancellationToken); + var latestVersion = GetCachedLatestVersion(); + if (latestVersion == null) + { + latestVersion = await GetLatestVersionFromNuGetAsync(cancellationToken); + if (latestVersion != null) + SaveCache(new VersionCheckCache(DateTimeOffset.UtcNow, latestVersion)); + } if (latestVersion == null) { @@ -48,27 +57,20 @@ public async Task CheckForUpdatesAsync(CancellationToken can return new VersionCheckResult(false, _currentVersion, null, null); } - // Compare versions var updateAvailable = IsNewerVersion(_currentVersion, latestVersion); - if (updateAvailable) - { - _logger.LogDebug("Update available: {LatestVersion} (current: {CurrentVersion})", latestVersion, _currentVersion); - } - else - { - _logger.LogDebug("Running latest version: {CurrentVersion}", _currentVersion); - } - - // Generate update command based on whether the latest version is a preview - var updateCommand = GetUpdateCommand(latestVersion); + _logger.LogDebug(updateAvailable + ? "Update available: {Latest} (current: {Current})" + : "Running latest version: {Current}", + latestVersion, _currentVersion); - return new VersionCheckResult(updateAvailable, _currentVersion, latestVersion, updateCommand); + return new VersionCheckResult(updateAvailable, _currentVersion, latestVersion, + VersionCheckHelper.GetUpdateCommand(latestVersion)); } catch (OperationCanceledException) { - _logger.LogDebug("Version check cancelled (timeout or user requested)"); - throw; // Re-throw to let caller handle timeout + _logger.LogDebug("Version check cancelled"); + throw; } catch (Exception ex) { @@ -77,58 +79,37 @@ public async Task CheckForUpdatesAsync(CancellationToken can } } - /// - /// Queries the NuGet V3 API to get the latest version of the package. - /// - /// - /// Uses HttpClientFactory.CreateAuthenticatedClient, which is the established pattern - /// in this codebase for creating HTTP clients. This is a static factory method that - /// properly configures and returns a new HttpClient instance. - /// private async Task GetLatestVersionFromNuGetAsync(CancellationToken cancellationToken) { try { using var httpClient = HttpClientFactory.CreateAuthenticatedClient(authToken: null); - using var response = await httpClient.GetAsync(NuGetApiUrl, cancellationToken); if (!response.IsSuccessStatusCode) { - _logger.LogDebug("NuGet API returned status code: {StatusCode}", response.StatusCode); + _logger.LogDebug("NuGet API returned {StatusCode}", response.StatusCode); return null; } var content = await response.Content.ReadAsStringAsync(cancellationToken); - - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var versionResponse = JsonSerializer.Deserialize(content, options); - + if (versionResponse?.Versions == null || versionResponse.Versions.Length == 0) { _logger.LogDebug("No versions found in NuGet response"); return null; } - // Sort versions semantically and return the latest - // NuGet API typically returns versions in chronological order, but we sort to be safe - var sortedVersions = versionResponse.Versions - .Select(v => new { Original = v, Parsed = TryParseVersion(v) }) + // Sort semantically — NuGet returns chronological order, but we sort to be safe + var sorted = versionResponse.Versions + .Select(v => new { Original = v, Parsed = VersionCheckHelper.TryParseVersion(v) }) .Where(v => v.Parsed != null) .OrderByDescending(v => v.Parsed) .ToList(); - if (sortedVersions.Count == 0) - { - _logger.LogDebug("No valid versions found in NuGet response"); - return null; - } - - return sortedVersions[0].Original; + return sorted.Count == 0 ? null : sorted[0].Original; } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -137,145 +118,69 @@ public async Task CheckForUpdatesAsync(CancellationToken can } } - /// - /// Compares two semantic versions to determine if the latest is newer than current. - /// - /// Current version string. - /// Latest version string from NuGet. - /// True if latest is newer than current. - private bool IsNewerVersion(string currentVersion, string latestVersion) + private bool IsNewerVersion(string current, string latest) { try { - // Parse semantic versions - var current = ParseVersion(currentVersion); - var latest = ParseVersion(latestVersion); - - // Compare versions - return latest > current; + return VersionCheckHelper.ParseVersion(latest) > VersionCheckHelper.ParseVersion(current); } catch (Exception ex) { - _logger.LogDebug(ex, "Failed to compare versions: current={Current}, latest={Latest}", currentVersion, latestVersion); + _logger.LogDebug(ex, "Failed to compare versions: current={Current}, latest={Latest}", current, latest); return false; } } - /// - /// Parses a semantic version string into a comparable Version object. - /// Handles formats like "1.1.0-preview.123+git.hash". - /// - internal Version ParseVersion(string versionString) - { - var parsed = TryParseVersion(versionString); - if (parsed == null) - { - throw new FormatException($"Invalid version format: {versionString}"); - } - return parsed; - } - - /// - /// Tries to parse a semantic version string into a comparable Version object. - /// Returns null if parsing fails. - /// - /// Note: This parsing treats preview versions as comparable by their preview number. - /// Handles two formats: - /// - "1.1.52-preview" (version number includes preview iteration) - /// - "1.1.0-preview.50" (preview number is separate) - /// - private Version? TryParseVersion(string versionString) + private string? GetCachedLatestVersion() { try { - // Remove any build metadata (+...) - var cleanVersion = versionString.Split('+')[0]; - - // For preview versions, extract the version number - if (cleanVersion.Contains('-')) - { - var parts = cleanVersion.Split('-'); - var baseVersion = parts[0]; // e.g., "1.1.52" or "1.1.0" + var path = GetCacheFilePath(); + if (!File.Exists(path)) + return null; - // Check if there's a preview number after the dash - if (parts.Length > 1) - { - var previewPart = parts[1]; // e.g., "preview" or "preview.50" + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var cache = JsonSerializer.Deserialize(File.ReadAllText(path), options); - // Format 1: "1.1.0-preview.50" - append preview number as revision - if (previewPart.StartsWith("preview.") && previewPart.Length > 8) - { - var previewNumber = previewPart.Substring(8); // Get number after "preview." - cleanVersion = int.TryParse(previewNumber, out var preview) - ? $"{baseVersion}.{preview}" - : baseVersion; // If parsing fails, just use base version - } - // Format 2: "1.1.52-preview" - version already includes iteration number - else - { - cleanVersion = baseVersion; - } - } - else - { - cleanVersion = baseVersion; - } - } + if (cache == null) + return null; - // Ensure we have at least 3 components for Version constructor - var versionParts = cleanVersion.Split('.'); - var componentsNeeded = 3 - versionParts.Length; - for (var i = 0; i < componentsNeeded; i++) + if (DateTimeOffset.UtcNow - cache.CachedAt >= TimeSpan.FromHours(CacheTtlHours)) { - cleanVersion += ".0"; + _logger.LogDebug("Version cache expired (cached at {CachedAt})", cache.CachedAt); + return null; } - return new Version(cleanVersion); + _logger.LogDebug("Using cached version {Version} (cached at {CachedAt})", cache.LatestVersion, cache.CachedAt); + return cache.LatestVersion; } - catch + catch (Exception ex) { + _logger.LogDebug(ex, "Could not load version cache"); return null; } } - /// - /// Generates the appropriate update command based on the version type. - /// - /// The version string to check for preview status. - /// The dotnet tool update command with or without --prerelease flag. - private static string GetUpdateCommand(string version) + private void SaveCache(VersionCheckCache cache) { - var baseCommand = $"dotnet tool update -g {PackageId}"; + try + { + var path = GetCacheFilePath(); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); - // If the version contains "preview", add the --prerelease flag - if (version.Contains("preview", StringComparison.OrdinalIgnoreCase)) + File.WriteAllText(path, JsonSerializer.Serialize(cache)); + } + catch (Exception ex) { - return $"{baseCommand} --prerelease"; + _logger.LogDebug(ex, "Could not save version cache"); } - - return baseCommand; } /// - /// Detects if the CLI is running in a CI/CD environment. + /// Returns the path to the on-disk version cache file. /// - private static bool IsRunningInCiCd() - { - // Common CI/CD environment variables - var ciEnvVars = new[] - { - "CI", // Generic CI indicator - "TF_BUILD", // Azure DevOps - "GITHUB_ACTIONS", // GitHub Actions - "JENKINS_HOME", // Jenkins - "GITLAB_CI", // GitLab CI - "CIRCLECI", // CircleCI - "TRAVIS", // Travis CI - "TEAMCITY_VERSION", // TeamCity - "BUILDKITE", // Buildkite - "CODEBUILD_BUILD_ID" // AWS CodeBuild - }; - - return ciEnvVars.Any(envVar => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envVar))); - } + internal static string GetCacheFilePath() + => Path.Combine(ConfigService.GetGlobalConfigDirectory(), CacheFileName); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/NoticeServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/NoticeServiceTests.cs new file mode 100644 index 00000000..76eda2e5 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/NoticeServiceTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +[Collection("VersionCheckTests")] +public class NoticeServiceTests : IDisposable +{ + private readonly ILogger _logger; + private readonly NoticeService _service; + + public NoticeServiceTests() + { + _logger = Substitute.For>(); + _service = new NoticeService(_logger); + } + + public void Dispose() + { + // Clean up cache file written by tests so state does not leak + var path = NoticeService.GetCacheFilePath(); + if (File.Exists(path)) + File.Delete(path); + } + + // --------------------------------------------------------------------------- + // CI/CD skip + // --------------------------------------------------------------------------- + + [Fact] + public async Task CheckForNoticeAsync_WhenRunningInCiCd_ReturnsNoNotice() + { + Environment.SetEnvironmentVariable("CI", "true"); + try + { + var result = await _service.CheckForNoticeAsync(); + result.HasNotice.Should().BeFalse(); + } + finally + { + Environment.SetEnvironmentVariable("CI", null); + } + } + + // --------------------------------------------------------------------------- + // Notice evaluation (cache-injected to avoid real network calls) + // --------------------------------------------------------------------------- + + [Fact] + public async Task CheckForNoticeAsync_WhenCacheHasNoMessage_ReturnsNoNotice() + { + WriteCacheFile(new NoticeCache(DateTimeOffset.UtcNow, new Notice(null, null, null))); + + var result = await _service.CheckForNoticeAsync(); + + result.HasNotice.Should().BeFalse(); + } + + [Fact] + public async Task CheckForNoticeAsync_WhenNoticeIsExpired_ReturnsNoNotice() + { + WriteCacheFile(new NoticeCache( + DateTimeOffset.UtcNow, + new Notice("Critical issue!", null, DateTimeOffset.UtcNow.AddDays(-1)))); + + var result = await _service.CheckForNoticeAsync(); + + result.HasNotice.Should().BeFalse(); + } + + [Fact] + public async Task CheckForNoticeAsync_WhenNoticeHasNoExpiry_ReturnsNotice() + { + WriteCacheFile(new NoticeCache( + DateTimeOffset.UtcNow, + new Notice("Critical issue - upgrade now.", null, null))); + + var result = await _service.CheckForNoticeAsync(); + + result.HasNotice.Should().BeTrue(); + result.Message.Should().Be("Critical issue - upgrade now."); + } + + [Fact] + public async Task CheckForNoticeAsync_WhenNoticeHasFutureExpiry_ReturnsNotice() + { + WriteCacheFile(new NoticeCache( + DateTimeOffset.UtcNow, + new Notice("Security advisory.", null, DateTimeOffset.UtcNow.AddDays(30)))); + + var result = await _service.CheckForNoticeAsync(); + + result.HasNotice.Should().BeTrue(); + result.Message.Should().Be("Security advisory."); + } + + [Fact] + public async Task CheckForNoticeAsync_WhenCurrentVersionMeetsMinimum_ReturnsNoNotice() + { + // Any real build version is above 0.0.1 + WriteCacheFile(new NoticeCache( + DateTimeOffset.UtcNow, + new Notice("Please upgrade.", "0.0.1", null))); + + var result = await _service.CheckForNoticeAsync(); + + result.HasNotice.Should().BeFalse(); + } + + [Fact] + public async Task CheckForNoticeAsync_WhenCurrentVersionBelowMinimum_ReturnsNotice() + { + // Any realistic build version is below 99.99.99 + WriteCacheFile(new NoticeCache( + DateTimeOffset.UtcNow, + new Notice("Please upgrade to v99.99.99.", "99.99.99", null))); + + var result = await _service.CheckForNoticeAsync(); + + result.HasNotice.Should().BeTrue(); + result.Message.Should().Be("Please upgrade to v99.99.99."); + result.UpdateCommand.Should().Contain("dotnet tool update") + .And.Contain("Microsoft.Agents.A365.DevTools.Cli"); + } + + // --------------------------------------------------------------------------- + // Cache file path + // --------------------------------------------------------------------------- + + [Fact] + public void GetCacheFilePath_ReturnsPathWithExpectedFileName() + { + Path.GetFileName(NoticeService.GetCacheFilePath()).Should().Be("notice.cache.json"); + } + + // --------------------------------------------------------------------------- + // Cancellation + // --------------------------------------------------------------------------- + + [Fact] + public async Task CheckForNoticeAsync_WhenCancelled_ThrowsOperationCanceledException() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Func act = async () => await _service.CheckForNoticeAsync(cts.Token); + + await act.Should().ThrowAsync(); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static void WriteCacheFile(NoticeCache cache) + { + var path = NoticeService.GetCacheFilePath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, JsonSerializer.Serialize(cache)); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VersionCheckServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VersionCheckServiceTests.cs index e58e8b88..c9c79151 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VersionCheckServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VersionCheckServiceTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -94,9 +95,9 @@ public async Task CheckForUpdatesAsync_WithTimeout_HandlesGracefully() [InlineData("1.1.0-preview.100", "1.1.0-preview.50", false)] // Current preview is newer public void ParseVersion_ComparesVersionsCorrectly(string current, string latest, bool expectedNewerAvailable) { - // Act - ParseVersion is internal, accessible to test assembly - var currentVersion = _versionCheckService.ParseVersion(current); - var latestVersion = _versionCheckService.ParseVersion(latest); + // Act - ParseVersion moved to VersionCheckHelper (internal, accessible to test assembly) + var currentVersion = VersionCheckHelper.ParseVersion(current); + var latestVersion = VersionCheckHelper.ParseVersion(latest); var isNewer = latestVersion > currentVersion; From 6045d308a151265258c0a229f5c3d145d51c0f71 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Wed, 11 Mar 2026 16:20:07 -0700 Subject: [PATCH 2/3] fix: address PR #316 review comments - Run notice and version checks concurrently via Task.WhenAll, capping worst-case startup delay at ~2s instead of ~4s - Fix NoticeServiceTests failing in CI: clear all CI env vars before tests that assert HasNotice=true, restore in finally - Fix CA2254 violation: use const string for log separator in Program.cs - Add CHANGELOG.md entry for the notice system feature Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + .../Program.cs | 29 ++++-- .../Services/NoticeServiceTests.cs | 91 ++++++++++++++----- 3 files changed, 90 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e711715..c671be19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- Server-driven notice system: security advisories and critical upgrade prompts are displayed at startup when a maintainer updates `notices.json`. Notices are suppressed once the user upgrades past the specified `minimumVersion`. Results are cached locally for 4 hours to avoid network calls on every invocation. - `a365 cleanup azure --dry-run` — preview resources that would be deleted without making any changes or requiring Azure authentication - `AppServiceAuthRequirementCheck` — validates App Service deployment token before `a365 deploy` begins, catching revoked grants (AADSTS50173) early ### Changed diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index ddb9a8ac..cf52a5c4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -48,16 +48,27 @@ static async Task Main(string[] args) ConfigureServices(services, logLevel, logFilePath); var serviceProvider = services.BuildServiceProvider(); - // Notice check — runs first, independent timeout so a slow network call - // cannot starve the version check. + // Notice and version checks run concurrently — worst-case startup delay is ~2s, not ~4s. + using var noticeCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + using var versionCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + var noticeService = serviceProvider.GetRequiredService(); + var versionCheckService = serviceProvider.GetRequiredService(); + + var noticeTask = noticeService.CheckForNoticeAsync(noticeCts.Token); + var versionTask = versionCheckService.CheckForUpdatesAsync(versionCts.Token); + + await Task.WhenAll( + noticeTask.ContinueWith(_ => { }, TaskContinuationOptions.None), + versionTask.ContinueWith(_ => { }, TaskContinuationOptions.None)); + + // Display notice result try { - var noticeService = serviceProvider.GetRequiredService(); - using var noticeCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - var noticeResult = await noticeService.CheckForNoticeAsync(noticeCts.Token); + var noticeResult = await noticeTask; if (noticeResult.HasNotice) { - var separator = new string('-', 60); + const string separator = "------------------------------------------------------------"; startupLogger.LogWarning(""); startupLogger.LogWarning(separator); startupLogger.LogWarning("URGENT NOTICE"); @@ -78,12 +89,10 @@ static async Task Main(string[] args) startupLogger.LogDebug(ex, "Notice check failed: {Message}", ex.Message); } - // Version update check — independent timeout + // Display version check result try { - var versionCheckService = serviceProvider.GetRequiredService(); - using var versionCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - var result = await versionCheckService.CheckForUpdatesAsync(versionCts.Token); + var result = await versionTask; if (result.UpdateAvailable) { startupLogger.LogWarning(""); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/NoticeServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/NoticeServiceTests.cs index 76eda2e5..11800040 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/NoticeServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/NoticeServiceTests.cs @@ -79,27 +79,43 @@ public async Task CheckForNoticeAsync_WhenNoticeIsExpired_ReturnsNoNotice() [Fact] public async Task CheckForNoticeAsync_WhenNoticeHasNoExpiry_ReturnsNotice() { - WriteCacheFile(new NoticeCache( - DateTimeOffset.UtcNow, - new Notice("Critical issue - upgrade now.", null, null))); + ClearCiEnvironment(); + try + { + WriteCacheFile(new NoticeCache( + DateTimeOffset.UtcNow, + new Notice("Critical issue - upgrade now.", null, null))); - var result = await _service.CheckForNoticeAsync(); + var result = await _service.CheckForNoticeAsync(); - result.HasNotice.Should().BeTrue(); - result.Message.Should().Be("Critical issue - upgrade now."); + result.HasNotice.Should().BeTrue(); + result.Message.Should().Be("Critical issue - upgrade now."); + } + finally + { + RestoreCiEnvironment(); + } } [Fact] public async Task CheckForNoticeAsync_WhenNoticeHasFutureExpiry_ReturnsNotice() { - WriteCacheFile(new NoticeCache( - DateTimeOffset.UtcNow, - new Notice("Security advisory.", null, DateTimeOffset.UtcNow.AddDays(30)))); + ClearCiEnvironment(); + try + { + WriteCacheFile(new NoticeCache( + DateTimeOffset.UtcNow, + new Notice("Security advisory.", null, DateTimeOffset.UtcNow.AddDays(30)))); - var result = await _service.CheckForNoticeAsync(); + var result = await _service.CheckForNoticeAsync(); - result.HasNotice.Should().BeTrue(); - result.Message.Should().Be("Security advisory."); + result.HasNotice.Should().BeTrue(); + result.Message.Should().Be("Security advisory."); + } + finally + { + RestoreCiEnvironment(); + } } [Fact] @@ -118,17 +134,25 @@ public async Task CheckForNoticeAsync_WhenCurrentVersionMeetsMinimum_ReturnsNoNo [Fact] public async Task CheckForNoticeAsync_WhenCurrentVersionBelowMinimum_ReturnsNotice() { - // Any realistic build version is below 99.99.99 - WriteCacheFile(new NoticeCache( - DateTimeOffset.UtcNow, - new Notice("Please upgrade to v99.99.99.", "99.99.99", null))); + ClearCiEnvironment(); + try + { + // Any realistic build version is below 99.99.99 + WriteCacheFile(new NoticeCache( + DateTimeOffset.UtcNow, + new Notice("Please upgrade to v99.99.99.", "99.99.99", null))); - var result = await _service.CheckForNoticeAsync(); + var result = await _service.CheckForNoticeAsync(); - result.HasNotice.Should().BeTrue(); - result.Message.Should().Be("Please upgrade to v99.99.99."); - result.UpdateCommand.Should().Contain("dotnet tool update") - .And.Contain("Microsoft.Agents.A365.DevTools.Cli"); + result.HasNotice.Should().BeTrue(); + result.Message.Should().Be("Please upgrade to v99.99.99."); + result.UpdateCommand.Should().Contain("dotnet tool update") + .And.Contain("Microsoft.Agents.A365.DevTools.Cli"); + } + finally + { + RestoreCiEnvironment(); + } } // --------------------------------------------------------------------------- @@ -166,4 +190,29 @@ private static void WriteCacheFile(NoticeCache cache) Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, JsonSerializer.Serialize(cache)); } + + // CI env vars that IsRunningInCiCd() checks — cleared so notice-display tests pass in CI. + private static readonly string[] CiEnvVars = + [ + "CI", "TF_BUILD", "GITHUB_ACTIONS", "JENKINS_HOME", "GITLAB_CI", + "CIRCLECI", "TRAVIS", "TEAMCITY_VERSION", "BUILDKITE", "CODEBUILD_BUILD_ID" + ]; + + private readonly Dictionary _savedCiEnv = new(); + + private void ClearCiEnvironment() + { + foreach (var key in CiEnvVars) + { + _savedCiEnv[key] = Environment.GetEnvironmentVariable(key); + Environment.SetEnvironmentVariable(key, null); + } + } + + private void RestoreCiEnvironment() + { + foreach (var (key, value) in _savedCiEnv) + Environment.SetEnvironmentVariable(key, value); + _savedCiEnv.Clear(); + } } From 73f7a960ba7e6f2c31fe14da7cc6b339da14f86e Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Wed, 11 Mar 2026 17:12:34 -0700 Subject: [PATCH 3/3] fix: add missing debug log and fix version log argument mismatch - Add 'No active notice' debug log when notice fetch returns empty message - Fix VersionCheckService log bug: 'Running latest version' was logging latestVersion instead of _currentVersion due to shared argument list across ternary log templates Co-Authored-By: Claude Sonnet 4.6 --- .../Services/NoticeService.cs | 3 +++ .../Services/VersionCheckService.cs | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/NoticeService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/NoticeService.cs index 3a9fdcea..2b59dcd3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/NoticeService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/NoticeService.cs @@ -44,7 +44,10 @@ public async Task CheckForNoticeAsync(CancellationToken cancellati var notice = await GetNoticeWithCacheAsync(cancellationToken); if (notice == null || string.IsNullOrWhiteSpace(notice.Message)) + { + _logger.LogDebug("No active notice"); return new NoticeResult(false, null, null); + } if (notice.ExpiresAt.HasValue && notice.ExpiresAt.Value <= DateTimeOffset.UtcNow) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs index d90d6305..1de90fd4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs @@ -59,10 +59,10 @@ public async Task CheckForUpdatesAsync(CancellationToken can var updateAvailable = IsNewerVersion(_currentVersion, latestVersion); - _logger.LogDebug(updateAvailable - ? "Update available: {Latest} (current: {Current})" - : "Running latest version: {Current}", - latestVersion, _currentVersion); + if (updateAvailable) + _logger.LogDebug("Update available: {Latest} (current: {Current})", latestVersion, _currentVersion); + else + _logger.LogDebug("Running latest version: {Current}", _currentVersion); return new VersionCheckResult(updateAvailable, _currentVersion, latestVersion, VersionCheckHelper.GetUpdateCommand(latestVersion));