Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cbdfdba
fix: device code fallback for macOS browser auth and cross-platform U…
sellakumaran Mar 4, 2026
6c8fe1d
fix: address PR review comments and handle Linux xdg_open_failed error
sellakumaran Mar 4, 2026
4da9b55
fix: auto-fix public client flows, add requirements check to blueprin…
sellakumaran Mar 4, 2026
3272376
fix: eliminate double auth on Linux, improve PS module error handling
sellakumaran Mar 4, 2026
2eae60e
fix: add system requirement checks to all setup commands that use Gra…
sellakumaran Mar 4, 2026
35bd6a0
Skip requirements on dry run; clarify browser fallback
sellakumaran Mar 4, 2026
f10f636
fix: skip requirements on dry run, self-correct missing PS modules in…
sellakumaran Mar 4, 2026
1550c0e
fix: extract real JWT from Graph request headers; unify PS auth scope…
sellakumaran Mar 4, 2026
bae2765
fix: extract last stdout line as JWT token; add clean error for missi…
sellakumaran Mar 4, 2026
5b58ab7
fix: unify PS token cache key in RemoveStale and DeployCommand; updat…
sellakumaran Mar 4, 2026
ed2ace6
fix: improve auth prompt UX and clarify custom permissions message
sellakumaran Mar 5, 2026
4fd11c7
fix: retry blueprint SP lookup on Azure AD propagation delay
sellakumaran Mar 5, 2026
ba0e867
fix: fix admin consent polling via MSAL token with Application.Read.All
sellakumaran Mar 5, 2026
c1e2486
fix: fall back to MSAL when PS Connect-MgGraph fails on any platform
sellakumaran Mar 5, 2026
ac390bf
fix: address PR review comments - disposal, logging, exit handling, d…
sellakumaran Mar 5, 2026
538f309
Merge with main.
sellakumaran Mar 5, 2026
1867f3a
fix: address second round of PR review comments
sellakumaran Mar 5, 2026
64b8c2d
chore: add CHANGELOG, NuGet release notes, and review process updates
sellakumaran Mar 5, 2026
cccd810
fix: address PR review comments - StringComparison, PSGallery, encodi…
sellakumaran Mar 5, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,20 @@ public static Command CreateCommand(
"--update-endpoint",
description: "Delete the existing messaging endpoint and register a new one with the specified URL");

var skipRequirementsOption = new Option<bool>(
"--skip-requirements",
description: "Skip requirements validation check\n" +
"Use with caution: setup may fail if prerequisites are not met");

command.AddOption(configOption);
command.AddOption(verboseOption);
command.AddOption(dryRunOption);
command.AddOption(skipEndpointRegistrationOption);
command.AddOption(endpointOnlyOption);
command.AddOption(updateEndpointOption);
command.AddOption(skipRequirementsOption);

command.SetHandler(async (config, verbose, dryRun, skipEndpointRegistration, endpointOnly, updateEndpoint) =>
command.SetHandler(async (config, verbose, dryRun, skipEndpointRegistration, endpointOnly, updateEndpoint, skipRequirements) =>
{
// Generate correlation ID at workflow entry point
var correlationId = HttpClientFactory.GenerateCorrelationId();
Expand Down Expand Up @@ -215,6 +221,37 @@ await UpdateEndpointAsync(
return;
}

// Run all requirements checks: system checks (PowerShell modules, Frontier Preview)
// and config checks (Location, ClientApp — includes isFallbackPublicClient auto-fix
// required for device code auth on macOS/Linux/WSL).
// Skip when dryRun is true: ClientAppRequirementCheck can mutate the app registration
// (e.g., set isFallbackPublicClient), which violates dry-run semantics.
if (!skipRequirements && !dryRun)
{
try
{
var requirementsResult = await RequirementsSubcommand.RunRequirementChecksAsync(
RequirementsSubcommand.GetRequirementChecks(clientAppValidator),
setupConfig,
logger,
category: null,
CancellationToken.None);
Comment thread
sellakumaran marked this conversation as resolved.

if (!requirementsResult)
{
logger.LogError("Setup cannot proceed due to the failed requirement checks above. Please fix the issues above and then try again.");
logger.LogError("Use the resolution guidance provided for each failed check.");
ExceptionHandler.ExitWithCleanup(1);
}
}
catch (Exception reqEx)
{
logger.LogError(reqEx, "Requirements check failed with an unexpected error: {Message}", reqEx.Message);
logger.LogError("If you want to bypass requirement validation, rerun this command with the --skip-requirements flag.");
ExceptionHandler.ExitWithCleanup(1);
}
}

if (dryRun)
{
logger.LogInformation("DRY RUN: Create Agent Blueprint");
Expand Down Expand Up @@ -281,7 +318,7 @@ await CreateBlueprintImplementationAsync(
correlationId: correlationId
);

}, configOption, verboseOption, dryRunOption, skipEndpointRegistrationOption, endpointOnlyOption, updateEndpointOption);
}, configOption, verboseOption, dryRunOption, skipEndpointRegistrationOption, endpointOnlyOption, updateEndpointOption, skipRequirementsOption);

return command;
}
Expand Down Expand Up @@ -1428,7 +1465,8 @@ await SetupHelpers.EnsureResourcePermissionsAsync(
logger.LogInformation("Requesting admin consent for application");
logger.LogInformation(" - Application scopes: {Scopes}", string.Join(", ", applicationScopes));
logger.LogInformation("Opening browser for Graph API admin consent...");
TryOpenBrowser(consentUrlGraph);
logger.LogInformation("If the browser does not open automatically, navigate to this URL to grant consent: {ConsentUrl}", consentUrlGraph);
BrowserHelper.TryOpenUrl(consentUrlGraph, logger);

var consentSuccess = await AdminConsentHelper.PollAdminConsentAsync(executor, logger, appId, "Graph API Scopes", 180, 5, ct);

Expand Down Expand Up @@ -1541,23 +1579,6 @@ private async static Task<GraphServiceClient> GetAuthenticatedGraphClientAsync(I
}
}

private static void TryOpenBrowser(string url)
{
try
{
using var p = new System.Diagnostics.Process();
p.StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = url,
UseShellExecute = true
};
p.Start();
}
catch
{
// non-fatal
}
}

/// <summary>
/// Creates client secret for Agent Blueprint (Phase 2.5)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ public static Command CreateCommand(
graphApiService.CustomClientAppId = setupConfig.ClientAppId;
}

// Verify system requirements (PowerShell modules are required for Graph operations).
// Skipped in dry-run: PowerShellModulesRequirementCheck can auto-install modules,
// which would be a side effect in a mode that is supposed to be non-mutating.
if (!dryRun)
{
var systemChecksOk = await RequirementsSubcommand.RunRequirementChecksAsync(
RequirementsSubcommand.GetSystemRequirementChecks(), setupConfig, logger, category: null, CancellationToken.None);
if (!systemChecksOk)
{
logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry.");
Environment.Exit(1);
}
Comment thread
sellakumaran marked this conversation as resolved.
}

if (dryRun)
{
logger.LogInformation("DRY RUN: Configure CopilotStudio Permissions");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@ private static Command CreateMcpSubcommand(
graphApiService.CustomClientAppId = setupConfig.ClientAppId;
}

// Verify system requirements (PowerShell modules are required for Graph operations).
// Skipped in dry-run: PowerShellModulesRequirementCheck can auto-install modules,
// which would be a side effect in a mode that is supposed to be non-mutating.
if (!dryRun)
{
var mcpSystemChecksOk = await RequirementsSubcommand.RunRequirementChecksAsync(
RequirementsSubcommand.GetSystemRequirementChecks(), setupConfig, logger, category: null, CancellationToken.None);
if (!mcpSystemChecksOk)
{
logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry.");
Environment.Exit(1);
}
Comment thread
sellakumaran marked this conversation as resolved.
}

if (dryRun)
{
// Read scopes from ToolingManifest.json
Expand Down Expand Up @@ -163,6 +177,20 @@ private static Command CreateBotSubcommand(
graphApiService.CustomClientAppId = setupConfig.ClientAppId;
}

// Verify system requirements (PowerShell modules are required for Graph operations).
// Skipped in dry-run: PowerShellModulesRequirementCheck can auto-install modules,
// which would be a side effect in a mode that is supposed to be non-mutating.
if (!dryRun)
{
var botSystemChecksOk = await RequirementsSubcommand.RunRequirementChecksAsync(
RequirementsSubcommand.GetSystemRequirementChecks(), setupConfig, logger, category: null, CancellationToken.None);
if (!botSystemChecksOk)
{
logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry.");
Environment.Exit(1);
}
}

if (dryRun)
{
logger.LogInformation("DRY RUN: Configure Bot API Permissions");
Expand Down Expand Up @@ -237,6 +265,20 @@ private static Command CreateCustomSubcommand(
graphApiService.CustomClientAppId = setupConfig.ClientAppId;
}

// Verify system requirements (PowerShell modules are required for Graph operations).
// Skipped in dry-run: PowerShellModulesRequirementCheck can auto-install modules,
// which would be a side effect in a mode that is supposed to be non-mutating.
if (!dryRun)
{
var customSystemChecksOk = await RequirementsSubcommand.RunRequirementChecksAsync(
RequirementsSubcommand.GetSystemRequirementChecks(), setupConfig, logger, category: null, CancellationToken.None);
if (!customSystemChecksOk)
{
logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry.");
Environment.Exit(1);
}
}

if (dryRun)
{
logger.LogInformation("DRY RUN: Configure Custom Blueprint Permissions");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ public static async Task EnsureResourcePermissionsAsync(
{
throw new SetupValidationException(
"Failed to authenticate to Microsoft Graph with delegated permissions. " +
"Please sign in when prompted and ensure your account has the required roles and permission scopes.");
"Check the errors above for the specific cause. Common causes: " +
"missing PowerShell module (run 'a365 setup requirements' to install), " +
"insufficient permissions, or sign-in was cancelled.");
}

var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, config.AgentBlueprintId, ct, permissionGrantScopes);
Expand Down Expand Up @@ -304,11 +306,11 @@ public static async Task EnsureResourcePermissionsAsync(
logger.LogInformation(" - Configuring inheritable permissions: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]",
config.AgentBlueprintId, resourceAppId, string.Join(' ', scopes));

// Use custom client app auth for inheritable permissions - Azure CLI doesn't support this operation
var requiredPermissions = new[] { "AgentIdentityBlueprint.UpdateAuthProperties.All", "Application.ReadWrite.All" };

// Use custom client app auth for inheritable permissions - Azure CLI doesn't support this operation.
// Reuse permissionGrantScopes (which already includes AgentIdentityBlueprint.UpdateAuthProperties.All)
// so all Graph PowerShell calls in this method share a single Connect-MgGraph session/cache entry.
var (ok, alreadyExists, err) = await blueprintService.SetInheritablePermissionsAsync(
config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, requiredScopes: requiredPermissions, ct);
config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, requiredScopes: permissionGrantScopes, ct);

if (!ok && !alreadyExists)
{
Expand Down Expand Up @@ -338,7 +340,7 @@ public static async Task EnsureResourcePermissionsAsync(
operation: async (ct) =>
{
var (exists, verifiedScopes, verifyError) = await blueprintService.VerifyInheritablePermissionsAsync(
config.TenantId, config.AgentBlueprintId, resourceAppId, ct, requiredPermissions);
config.TenantId, config.AgentBlueprintId, resourceAppId, ct, permissionGrantScopes);
return (exists, verifiedScopes, verifyError);
},
shouldRetry: (result) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,17 @@ public static string[] GetRequiredRedirectUris(string clientAppId)
};

/// <summary>
/// Required scopes for oauth2 permission grants to service principals.
/// These scopes enable the service principals to operate correctly with the necessary permissions.
/// All scopes require admin consent.
/// Required scopes for all PowerShell-based Microsoft Graph operations (OAuth2 grants,
/// service principal lookups, and inheritable permissions).
/// Using a single unified set ensures Connect-MgGraph authenticates once and the resulting
/// token is reused from the in-process cache for all downstream Graph operations.
/// All scopes require admin consent and are included in RequiredClientAppPermissions.
/// </summary>
public static readonly string[] RequiredPermissionGrantScopes = new[]
{
"Application.ReadWrite.All",
"DelegatedPermissionGrant.ReadWrite.All"
"DelegatedPermissionGrant.ReadWrite.All",
"AgentIdentityBlueprint.UpdateAuthProperties.All"
};

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

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

/// <summary>
/// Exception thrown when the a365.config.json configuration file cannot be found.
/// This is a USER ERROR - the file is missing or the command was run from the wrong directory.
/// </summary>
public class ConfigFileNotFoundException : Agent365Exception
{
public ConfigFileNotFoundException(string configFilePath)
: base(
errorCode: "CONFIG_FILE_NOT_FOUND",
issueDescription: $"Configuration file not found: {configFilePath}",
mitigationSteps: new List<string>
{
"Make sure you are running this command from your agent project directory.",
"If you have not created a configuration file yet, run: a365 config init"
},
context: new Dictionary<string, string>
{
["ConfigFile"] = configFilePath
})
{
}

public override int ExitCode => 2; // Configuration error
}
57 changes: 57 additions & 0 deletions src/Microsoft.Agents.A365.DevTools.Cli/Helpers/BrowserHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;

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

/// <summary>
/// Helper methods for cross-platform browser and URL operations.
/// </summary>
public static class BrowserHelper
{
/// <summary>
/// Opens a URL in the system's default browser in a cross-platform way.
/// Non-fatal: if the browser cannot be launched, logs a warning via <paramref name="logger"/>
/// when provided, or writes to <see cref="Console.Error"/> when logger is null.
/// The fallback URL is always emitted so the user can open it manually.
/// </summary>
/// <param name="url">The URL to open.</param>
/// <param name="logger">Optional logger for diagnostic messages.</param>
public static void TryOpenUrl(string url, ILogger? logger = null)
{
Comment thread
sellakumaran marked this conversation as resolved.
try
{
ProcessStartInfo psi;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
psi = new ProcessStartInfo { FileName = url, UseShellExecute = true };
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
psi = new ProcessStartInfo { FileName = "open", Arguments = url };
}
else
{
psi = new ProcessStartInfo { FileName = "xdg-open", Arguments = url };
}
using var process = new Process { StartInfo = psi };
process.Start();
Comment thread
sellakumaran marked this conversation as resolved.
}
catch (Exception ex)
{
if (logger != null)
{
logger.LogWarning("Failed to open browser automatically: {Message}", ex.Message);
Comment thread
sellakumaran marked this conversation as resolved.
Outdated
logger.LogInformation("Please manually open: {Url}", url);
}
else
{
Console.Error.WriteLine($"Failed to open browser automatically: {ex.Message}");
Console.Error.WriteLine($"Please manually open: {url}");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
// Licensed under the MIT License.

using Microsoft.Agents.A365.DevTools.Cli.Constants;
using Microsoft.Agents.A365.DevTools.Cli.Helpers;
using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;
using Microsoft.Agents.A365.DevTools.Cli.Services.Internal;
using Microsoft.Extensions.Logging;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
Expand Down Expand Up @@ -1046,7 +1046,7 @@ private async Task<bool> RequestAdminConsentAsync(
_logger.LogInformation("URL: {Url}", consentUrl);

// Open browser
TryOpenBrowser(consentUrl);
BrowserHelper.TryOpenUrl(consentUrl, _logger);

_logger.LogInformation("");
_logger.LogInformation("Waiting for admin consent (timeout: {Timeout} seconds)...", timeoutSeconds);
Expand Down Expand Up @@ -1131,24 +1131,6 @@ private async Task<bool> RequestAdminConsentAsync(
return false;
}

private void TryOpenBrowser(string url)
{
try
{
using var process = new System.Diagnostics.Process();
process.StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = url,
UseShellExecute = true
};
process.Start();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to open browser automatically");
_logger.LogInformation("Please manually open: {Url}", url);
}
}

/// <summary>
/// Verify that a service principal exists in Azure AD for the given app ID.
Expand Down
Loading
Loading