Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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,35 @@ 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).
if (!skipRequirements)
{
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 +316,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 +1463,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 +1577,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,15 @@ public static Command CreateCommand(
graphApiService.CustomClientAppId = setupConfig.ClientAppId;
}

// Verify system requirements (PowerShell modules are required for Graph operations)
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.
Outdated

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,15 @@ private static Command CreateMcpSubcommand(
graphApiService.CustomClientAppId = setupConfig.ClientAppId;
}

// Verify system requirements (PowerShell modules are required for Graph operations)
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.
Outdated

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

// Verify system requirements (PowerShell modules are required for Graph operations)
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 +255,15 @@ private static Command CreateCustomSubcommand(
graphApiService.CustomClientAppId = setupConfig.ClientAppId;
}

// Verify system requirements (PowerShell modules are required for Graph operations)
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
55 changes: 55 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,55 @@
// 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: logs a warning and prints the URL if the browser cannot be launched.
/// </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
Original file line number Diff line number Diff line change
Expand Up @@ -535,14 +535,14 @@ protected virtual TokenCredential CreateDeviceCodeCredential(string clientId, st
},
DeviceCodeCallback = (code, cancellation) =>
{
Console.WriteLine();
Console.WriteLine("==========================================================================");
Console.WriteLine($"To sign in, use a web browser to open the page:");
Console.WriteLine($" {code.VerificationUri}");
Console.WriteLine();
Console.WriteLine($"And enter the code: {code.UserCode}");
Console.WriteLine("==========================================================================");
Console.WriteLine();
_logger.LogInformation("");
_logger.LogInformation("==========================================================================");
_logger.LogInformation("To sign in, use a web browser to open the page:");
_logger.LogInformation(" {VerificationUri}", code.VerificationUri);
_logger.LogInformation("");
_logger.LogInformation("And enter the code: {UserCode}", code.UserCode);
_logger.LogInformation("==========================================================================");
_logger.LogInformation("");
return Task.CompletedTask;
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ public async Task EnsureValidClientAppAsync(
// Step 6: Verify and fix redirect URIs
await EnsureRedirectUrisAsync(clientAppId, graphToken, ct);

// Step 7: Verify and fix public client flows (required for device code fallback on non-Windows)
await EnsurePublicClientFlowsEnabledAsync(clientAppId, graphToken, ct);

_logger.LogDebug("Client app validation successful for {ClientAppId}", clientAppId);
}
catch (ClientAppValidationException)
Expand Down Expand Up @@ -237,6 +240,82 @@ public async Task EnsureRedirectUrisAsync(
}
}

/// <summary>
/// Ensures the app registration has "Allow public client flows" enabled.
/// This setting is required for MSAL device code authentication fallback on non-Windows
/// platforms where interactive browser auth is unavailable (macOS headless, Linux, WSL).
/// Automatically enables it if disabled (self-healing).
/// </summary>
private async Task EnsurePublicClientFlowsEnabledAsync(
string clientAppId,
string graphToken,
CancellationToken ct = default)
{
try
{
_logger.LogDebug("Checking 'Allow public client flows' for client app {ClientAppId}", clientAppId);

var appCheckResult = await _executor.ExecuteAsync(
"az",
$"rest --method GET --url \"{GraphApiBaseUrl}/applications?$filter=appId eq '{CommandStringHelper.EscapePowerShellString(clientAppId)}'&$select=id,isFallbackPublicClient\" --headers \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(graphToken)}\"",
cancellationToken: ct);

if (!appCheckResult.Success)
{
_logger.LogWarning("Could not check 'Allow public client flows': {Error}", appCheckResult.StandardError);
return;
}

var sanitizedOutput = JsonDeserializationHelper.CleanAzureCliJsonOutput(appCheckResult.StandardOutput);
var response = JsonNode.Parse(sanitizedOutput);
var apps = response?["value"]?.AsArray();

if (apps == null || apps.Count == 0)
{
_logger.LogWarning("Client app not found when checking 'Allow public client flows'");
return;
}

var app = apps[0]!.AsObject();
var objectId = app["id"]?.GetValue<string>();

if (string.IsNullOrWhiteSpace(objectId))
{
_logger.LogWarning("Could not get application object ID when checking 'Allow public client flows'");
return;
}

var isFallbackPublicClient = app["isFallbackPublicClient"]?.GetValue<bool>() ?? false;
if (isFallbackPublicClient)
{
_logger.LogDebug("'Allow public client flows' is already enabled");
return;
}

_logger.LogInformation("Enabling 'Allow public client flows' on app registration (required for device code authentication fallback on macOS/Linux).");
_logger.LogInformation("Run 'a365 setup requirements' at any time to re-verify and auto-fix this setting.");

var patchBody = "{\"isFallbackPublicClient\":true}";
var escapedBody = patchBody.Replace("\"", "\"\"");
var patchResult = await _executor.ExecuteAsync(
"az",
$"rest --method PATCH --url \"{GraphApiBaseUrl}/applications/{CommandStringHelper.EscapePowerShellString(objectId)}\" --headers \"Content-Type=application/json\" \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(graphToken)}\" --body \"{escapedBody}\"",
cancellationToken: ct);

if (!patchResult.Success)
{
_logger.LogWarning("Failed to enable 'Allow public client flows': {Error}", patchResult.StandardError);
return;
}

_logger.LogInformation("Successfully enabled 'Allow public client flows' on app registration.");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error ensuring 'Allow public client flows' is enabled (non-fatal)");
}
}

#region Private Helper Methods

private async Task<string?> AcquireGraphTokenAsync(CancellationToken ct)
Expand Down
Loading
Loading