Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions notices.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"message": "",
"minimumVersion": null,
"expiresAt": "2000-01-01T00:00:00Z"
}
23 changes: 23 additions & 0 deletions src/Microsoft.Agents.A365.DevTools.Cli/Models/NoticeModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Microsoft.Agents.A365.DevTools.Cli.Models;

/// <summary>
/// Notice fetched from the server-side notices endpoint.
/// All fields are nullable — an empty or partial response means no active notice.
/// </summary>
public record Notice(
string? Message,
string? MinimumVersion,
DateTimeOffset? ExpiresAt);

/// <summary>
/// Result of a notice check — what the caller acts on.
/// </summary>
public record NoticeResult(bool HasNotice, string? Message, string? UpdateCommand);

/// <summary>
/// On-disk cache envelope for the notice, keyed by fetch timestamp.
/// </summary>
public record NoticeCache(DateTimeOffset CachedAt, Notice? ActiveNotice);
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ public record VersionCheckResult(
string? CurrentVersion,
string? LatestVersion,
string? UpdateCommand);

/// <summary>
/// On-disk cache envelope for the version check result, keyed by fetch timestamp.
/// </summary>
public record VersionCheckCache(DateTimeOffset CachedAt, string? LatestVersion);
47 changes: 43 additions & 4 deletions src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,51 @@ static async Task<int> Main(string[] args)
ConfigureServices(services, logLevel, logFilePath);
var serviceProvider = services.BuildServiceProvider();

// Check for updates (non-blocking, with timeout)
// 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<INoticeService>();
var versionCheckService = serviceProvider.GetRequiredService<IVersionCheckService>();

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 versionCheckService = serviceProvider.GetRequiredService<IVersionCheckService>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var result = await versionCheckService.CheckForUpdatesAsync(cts.Token);
var noticeResult = await noticeTask;
if (noticeResult.HasNotice)
{
const string separator = "------------------------------------------------------------";
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);
}

// Display version check result
try
{
var result = await versionTask;
if (result.UpdateAvailable)
{
startupLogger.LogWarning("");
Expand Down Expand Up @@ -194,6 +232,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini
services.AddSingleton<AuthenticationService>();
services.AddSingleton<IClientAppValidator, ClientAppValidator>();
services.AddSingleton<IVersionCheckService, VersionCheckService>();
services.AddSingleton<INoticeService, NoticeService>();

// Add Microsoft Agent 365 Tooling Service with environment detection
services.AddSingleton<IAgent365ToolingService>(provider =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Service for checking whether an active notice should be shown to the user.
/// </summary>
public interface INoticeService
{
/// <summary>
/// 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.
/// </summary>
/// <param name="cancellationToken">Cancellation token to abort the check.</param>
/// <returns>Result indicating whether there is an active notice to display.</returns>
Task<NoticeResult> CheckForNoticeAsync(CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
namespace Microsoft.Agents.A365.DevTools.Cli.Services;

/// <summary>
/// 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.
/// </summary>
public interface IVersionCheckService
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Microsoft.Agents.A365.DevTools.Cli.Services.Internal;

/// <summary>
/// Shared helpers used by both <see cref="Services.VersionCheckService"/> and
/// <see cref="Services.NoticeService"/>. Internal to the assembly.
/// </summary>
internal static class VersionCheckHelper
{
private const string PackageId = "Microsoft.Agents.A365.DevTools.Cli";

/// <summary>
/// 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.
/// </summary>
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)));
}

/// <summary>
/// Parses a semantic version string into a comparable <see cref="Version"/>.
/// Handles formats such as "1.1.52", "1.1.0-preview.50", and "1.1.52-preview".
/// Throws <see cref="FormatException"/> if parsing fails.
/// </summary>
internal static Version ParseVersion(string versionString)
{
var parsed = TryParseVersion(versionString);
if (parsed == null)
throw new FormatException($"Invalid version format: {versionString}");
return parsed;
}

/// <summary>
/// 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)
/// </summary>
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;
}
}

/// <summary>
/// Returns the appropriate <c>dotnet tool update</c> command for the given version string.
/// Appends <c>--prerelease</c> when the version is a preview build.
/// </summary>
internal static string GetUpdateCommand(string version)
{
var baseCommand = $"dotnet tool update -g {PackageId}";
return version.Contains("preview", StringComparison.OrdinalIgnoreCase)
? $"{baseCommand} --prerelease"
: baseCommand;
}
}
Loading
Loading