diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs
index a7329d67..57a2b11e 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs
@@ -1,714 +1,59 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-using Microsoft.Agents.A365.DevTools.Cli.Constants;
-using Microsoft.Agents.A365.DevTools.Cli.Exceptions;
-using Microsoft.Agents.A365.DevTools.Cli.Helpers;
-using Microsoft.Agents.A365.DevTools.Cli.Models;
+using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands;
using Microsoft.Agents.A365.DevTools.Cli.Services;
-using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;
using Microsoft.Extensions.Logging;
using System.CommandLine;
-using System.Text.Json;
-namespace Microsoft.Agents.A365.DevTools.Cli.Commands;
-
-///
-/// Setup command - Complete initial agent deployment (blueprint, messaging endpoint registration) in one step
-///
-public class SetupCommand
+namespace Microsoft.Agents.A365.DevTools.Cli.Commands
{
- // Test hook: if set, this delegate will be invoked instead of creating/running the real A365SetupRunner.
- // Signature: (setupConfigPath, generatedConfigPath, executor, webAppCreator) => Task
- public static Func>? SetupRunnerInvoker { get; set; }
-
- public static Command CreateCommand(
- ILogger logger,
- IConfigService configService,
- CommandExecutor executor,
- DeploymentService deploymentService, // still injected for future use, not used here
- IBotConfigurator botConfigurator,
- IAzureValidator azureValidator,
- AzureWebAppCreator webAppCreator,
- PlatformDetector platformDetector,
- GraphApiService graphApiService)
- {
- var command = new Command("setup", "Set up your Agent 365 environment by creating Azure resources, configuring\npermissions, and registering your agent blueprint for deployment");
-
- // Options for the main setup command
- var configOption = new Option(
- ["--config", "-c"],
- getDefaultValue: () => new FileInfo("a365.config.json"),
- description: "Setup configuration file path");
-
- var verboseOption = new Option(
- ["--verbose", "-v"],
- description: "Show detailed output");
-
- var dryRunOption = new Option(
- "--dry-run",
- description: "Show what would be done without executing");
-
- var blueprintOnlyOption = new Option(
- "--blueprint",
- description: "Skip Azure infrastructure setup and create blueprint only. ");
-
- command.AddOption(configOption);
- command.AddOption(verboseOption);
- command.AddOption(dryRunOption);
- command.AddOption(blueprintOnlyOption);
-
- // No subcommands - all logic is in the main handler
- command.SetHandler(async (config, verbose, dryRun, blueprintOnly) =>
- {
- if (dryRun)
- {
- // Validate configuration even in dry-run mode
- var dryRunConfig = await configService.LoadAsync(config.FullName);
-
- logger.LogInformation("DRY RUN: Agent 365 Setup - Blueprint + Messaging Endpoint Registration");
- logger.LogInformation("This would execute the following operations:");
- logger.LogInformation(" 1. Create agent blueprint and Azure resources");
- logger.LogInformation(" 2. Register blueprint messaging endpoint");
- logger.LogInformation("No actual changes will be made.");
- logger.LogInformation("Configuration file validated successfully: {ConfigFile}", config.FullName);
- return;
- }
-
- logger.LogInformation("Agent 365 Setup - Blueprint + Messaging Endpoint Registration");
- logger.LogInformation("Creating blueprint and registering messaging endpoint...");
- logger.LogInformation("");
-
- // Track setup results for summary
- var setupResults = new SetupResults();
-
- try
- {
- // Load configuration - ConfigService automatically finds generated config in same directory
- var setupConfig = await configService.LoadAsync(config.FullName);
- if (setupConfig.NeedDeployment)
- {
- // Validate Azure CLI authentication, subscription, and environment
- if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId))
- {
- Environment.Exit(1);
- }
- }
- else
- {
- logger.LogInformation("NeedDeployment=false – skipping Azure subscription validation.");
- }
-
- logger.LogInformation("");
-
- // Step 1: Create blueprint
- logger.LogInformation("Step 1: Creating agent blueprint...");
- logger.LogInformation("");
-
- var generatedConfigPath = Path.Combine(
- config.DirectoryName ?? Environment.CurrentDirectory,
- "a365.generated.config.json");
-
- bool success;
-
- var delegatedConsentService = new DelegatedConsentService(
- LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(),
- graphApiService);
-
- var setupRunner = new A365SetupRunner(
- LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(),
- executor,
- graphApiService,
- webAppCreator,
- delegatedConsentService,
- platformDetector);
-
- // Use test invoker if set (for testing), otherwise use real runner
- if (SetupRunnerInvoker != null)
- {
- success = await SetupRunnerInvoker(config.FullName, generatedConfigPath, executor, webAppCreator);
-
- // If using test invoker, stop here - tests don't mock all the downstream services
- if (success)
- {
- logger.LogDebug("Generated config saved at: {Path}", generatedConfigPath);
- logger.LogInformation("Setup completed successfully (test mode)");
- return;
- }
- }
- else
- {
- // Pass blueprintOnly option to setup runner
- success = await setupRunner.RunAsync(config.FullName, generatedConfigPath, blueprintOnly);
- }
-
- if (!success)
- {
- logger.LogError("Agent blueprint creation failed");
- setupResults.BlueprintCreated = false;
- setupResults.Errors.Add("Agent blueprint creation failed");
- throw new SetupValidationException("Setup runner execution failed");
- }
-
- setupResults.BlueprintCreated = true;
-
- // Reload config to get blueprint ID and check for inheritable permissions status
- var tempConfig = await configService.LoadAsync(config.FullName);
- setupResults.BlueprintId = tempConfig.AgentBlueprintId;
-
- logger.LogInformation("Agent blueprint created successfully");
- logger.LogDebug("Generated config saved at: {Path}", generatedConfigPath);
-
- logger.LogInformation("");
- logger.LogInformation("Step 2a: Applying MCP server permissions (OAuth2 permission grants + inheritable permissions)");
- logger.LogInformation("");
-
- // Reload configuration to pick up blueprint ID from generated config
- // ConfigService automatically resolves generated config in same directory
- setupConfig = await configService.LoadAsync(config.FullName);
-
- // Read scopes from toolingManifest.json (at deploymentProjectPath)
- var manifestPath = Path.Combine(setupConfig.DeploymentProjectPath ?? string.Empty, "toolingManifest.json");
- var toolingScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath);
-
- // Apply OAuth2 permission grant (admin consent) and inheritable permissions
- // Wrap in try-catch to prevent unhandled exceptions
- try
- {
- await EnsureMcpOauth2PermissionGrantsAsync(
- graphApiService,
- setupConfig,
- toolingScopes,
- logger
- );
-
- // Apply inheritable permissions on the agent identity blueprint
- await EnsureMcpInheritablePermissionsAsync(
- graphApiService,
- setupConfig,
- toolingScopes,
- logger
- );
-
- setupResults.McpPermissionsConfigured = true;
- logger.LogInformation("MCP server permissions configured successfully");
- // Check if inheritable permissions were configured successfully
- // The A365SetupRunner sets this flag in generated config
- setupResults.InheritablePermissionsConfigured = tempConfig.InheritanceConfigured;
-
- if (!tempConfig.InheritanceConfigured)
- {
- setupResults.Warnings.Add("Inheritable permissions configuration incomplete");
-
- if (!string.IsNullOrEmpty(tempConfig.InheritanceConfigError))
- {
- setupResults.Warnings.Add($"Inheritable permissions error: {tempConfig.InheritanceConfigError}");
- }
- }
- }
- catch (Exception mcpEx)
- {
- setupResults.McpPermissionsConfigured = false;
- setupResults.InheritablePermissionsConfigured = false; // ADD THIS LINE
- setupResults.Errors.Add($"MCP permissions: {mcpEx.Message}");
- logger.LogError("Failed to configure MCP server permissions: {Message}", mcpEx.Message);
- logger.LogWarning("Setup will continue, but MCP server permissions must be configured manually");
- logger.LogInformation("To configure MCP permissions manually:");
- logger.LogInformation(" 1. Ensure the agent blueprint has the required permissions in Azure Portal");
- logger.LogInformation(" 2. Grant admin consent for the MCP scopes");
- logger.LogInformation(" 3. Run 'a365 deploy mcp' to retry MCP permission configuration");
- }
-
- logger.LogInformation("");
- logger.LogInformation("Step 2b: add Messaging Bot API permission + inheritable permissions");
- logger.LogInformation("");
-
- if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId))
- throw new SetupValidationException("AgentBlueprintId is required.");
-
- var blueprintSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync(setupConfig.TenantId, setupConfig.AgentBlueprintId)
- ?? throw new SetupValidationException($"Blueprint Service Principal not found for appId {setupConfig.AgentBlueprintId}");
-
- // Ensure Messaging Bot API SP exists
- var botApiResourceSpObjectId = await graphApiService.EnsureServicePrincipalForAppIdAsync(
- setupConfig.TenantId,
- ConfigConstants.MessagingBotApiAppId);
-
- try
- {
- // Grant oauth2PermissionGrants: blueprint SP -> Messaging Bot API SP
- var botApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync(
- setupConfig.TenantId,
- blueprintSpObjectId,
- botApiResourceSpObjectId,
- new[] { "Authorization.ReadWrite", "user_impersonation" });
-
- if (!botApiGrantOk)
- {
- setupResults.Warnings.Add("Failed to create/update oauth2PermissionGrant for Messaging Bot API");
- logger.LogWarning("Failed to create/update oauth2PermissionGrant for Messaging Bot API.");
- }
-
- // Add inheritable permissions on blueprint for Messaging Bot API
- var (ok, already, err) = await graphApiService.SetInheritablePermissionsAsync(
- setupConfig.TenantId,
- setupConfig.AgentBlueprintId,
- ConfigConstants.MessagingBotApiAppId,
- new[] { "Authorization.ReadWrite", "user_impersonation" });
-
- if (!ok && !already)
- {
- setupResults.Warnings.Add($"Failed to set inheritable permissions for Messaging Bot API: {err}");
- logger.LogWarning("Failed to set inheritable permissions for Messaging Bot API: " + err);
- }
-
- setupResults.BotApiPermissionsConfigured = true;
- logger.LogInformation("Messaging Bot API permissions configured (grant + inheritable) successfully.");
- }
- catch (Exception botEx)
- {
- setupResults.BotApiPermissionsConfigured = false;
- setupResults.Errors.Add($"Bot API permissions: {botEx.Message}");
- logger.LogError("Failed to configure Messaging Bot API permissions: {Message}", botEx.Message);
- }
-
- logger.LogInformation("");
- logger.LogInformation("Step 3: Registering blueprint messaging endpoint...");
-
- try
- {
- // Reload config to get any updated values from blueprint creation
- setupConfig = await configService.LoadAsync(config.FullName);
-
- await RegisterBlueprintMessagingEndpointAsync(setupConfig, logger, botConfigurator);
- await configService.SaveStateAsync(setupConfig);
- setupResults.MessagingEndpointRegistered = true;
- logger.LogInformation("Blueprint messaging endpoint registered successfully");
- }
- catch (Exception endpointEx)
- {
- setupResults.MessagingEndpointRegistered = false;
- setupResults.Errors.Add($"Messaging endpoint: {endpointEx.Message}");
- logger.LogError("Failed to register messaging endpoint: {Message}", endpointEx.Message);
- }
-
- // Sync generated config in project settings from deployment project
- try
- {
- generatedConfigPath = Path.Combine(
- config.DirectoryName ?? Environment.CurrentDirectory,
- "a365.generated.config.json");
- await ProjectSettingsSyncHelper.ExecuteAsync(
- a365ConfigPath: config.FullName,
- a365GeneratedPath: generatedConfigPath,
- configService: configService,
- platformDetector: platformDetector,
- logger: logger
- );
-
- logger.LogDebug("Generated config synced to project settings");
- }
- catch (Exception syncEx)
- {
- logger.LogWarning(syncEx, "Project settings sync failed (non-blocking). Please sync settings manually.");
- }
-
- // Display verification URLs and next steps
- await DisplayVerificationInfoAsync(config, logger);
-
- // Display comprehensive setup summary
- DisplaySetupSummary(setupResults, logger);
- }
- catch (Agent365Exception ex)
- {
- ExceptionHandler.HandleAgent365Exception(ex);
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "Setup failed: {Message}", ex.Message);
- throw;
- }
- }, configOption, verboseOption, dryRunOption, blueprintOnlyOption);
-
- return command;
- }
-
-
- ///
- /// Display verification URLs and next steps after successful setup
- ///
- private static async Task DisplayVerificationInfoAsync(FileInfo setupConfigFile, ILogger logger)
- {
- try
- {
- logger.LogInformation("Generating verification information...");
- var baseDir = setupConfigFile.DirectoryName ?? Environment.CurrentDirectory;
- var generatedConfigPath = Path.Combine(baseDir, "a365.generated.config.json");
-
- if (!File.Exists(generatedConfigPath))
- {
- logger.LogWarning("Generated config not found - skipping verification info");
- return;
- }
-
- using var stream = File.OpenRead(generatedConfigPath);
- using var doc = await JsonDocument.ParseAsync(stream);
- var root = doc.RootElement;
-
- logger.LogInformation("");
- logger.LogInformation("Verification URLs and Next Steps:");
- logger.LogInformation("==========================================");
-
- // Azure Web App URL - construct from AppServiceName
- if (root.TryGetProperty("AppServiceName", out var appServiceProp) && !string.IsNullOrWhiteSpace(appServiceProp.GetString()))
- {
- var webAppUrl = $"https://{appServiceProp.GetString()}.azurewebsites.net";
- logger.LogInformation("Agent Web App: {Url}", webAppUrl);
- }
-
- // Azure Resource Group
- if (root.TryGetProperty("ResourceGroup", out var rgProp) && !string.IsNullOrWhiteSpace(rgProp.GetString()))
- {
- var resourceGroup = rgProp.GetString();
- logger.LogInformation("Azure Resource Group: https://portal.azure.com/#@/resource/subscriptions/{SubscriptionId}/resourceGroups/{ResourceGroup}",
- root.TryGetProperty("SubscriptionId", out var subProp) ? subProp.GetString() : "{subscription}",
- resourceGroup);
- }
-
- // Entra ID Application
- if (root.TryGetProperty("AgentBlueprintId", out var blueprintProp) && !string.IsNullOrWhiteSpace(blueprintProp.GetString()))
- {
- logger.LogInformation("Entra ID Application: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/{AppId}",
- blueprintProp.GetString());
- }
-
- logger.LogInformation("");
- logger.LogInformation("Next Steps:");
- logger.LogInformation(" 1. Review Azure resources in the portal");
- logger.LogInformation(" 2. View configuration: a365 config display");
- logger.LogInformation(" 3. Create agent instance: a365 create-instance identity");
- logger.LogInformation(" 4. Deploy application: a365 deploy app");
- logger.LogInformation("");
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "Could not display verification info: {Message}", ex.Message);
- }
- }
-
- ///
- /// Register blueprint messaging endpoint using deployed web app URL
- ///
- private static async Task RegisterBlueprintMessagingEndpointAsync(
- Agent365Config setupConfig,
- ILogger logger,
- IBotConfigurator botConfigurator)
- {
- // Validate required configuration
- if (string.IsNullOrEmpty(setupConfig.AgentBlueprintId))
- {
- logger.LogError("Agent Blueprint ID not found. Blueprint creation may have failed.");
- throw new SetupValidationException(
- issueDescription: "Agent blueprint was not found – messaging endpoint cannot be registered.",
- errorDetails: new List
- {
- "AgentBlueprintId is missing from configuration. This usually means the blueprint creation step failed or a365.generated.config.json is out of sync."
- },
- mitigationSteps: new List
- {
- "Verify that 'a365 setup' completed Step 1 (Agent blueprint creation) without errors.",
- "Check a365.generated.config.json for 'agentBlueprintId'. If it's missing or incorrect, re-run 'a365 setup'."
- },
- context: new Dictionary
- {
- ["AgentBlueprintId"] = setupConfig.AgentBlueprintId ?? ""
- });
- }
-
- string messagingEndpoint;
- string endpointName;
- if (setupConfig.NeedDeployment)
- {
- if (string.IsNullOrEmpty(setupConfig.WebAppName))
- {
- logger.LogError("Web App Name not configured in a365.config.json");
- throw new SetupValidationException(
- issueDescription: "Web App name is required to register a messaging endpoint when needDeployment is 'yes'.",
- errorDetails: new List
- {
- "NeedDeployment is true, but 'webAppName' was not provided in a365.config.json."
- },
- mitigationSteps: new List
- {
- "Open a365.config.json and ensure 'webAppName' is set to the Azure Web App name.",
- "If you do not want the CLI to deploy an Azure Web App, set \"needDeployment\": \"no\" and provide \"MessagingEndpoint\" instead.",
- "Re-run 'a365 setup'."
- },
- context: new Dictionary
- {
- ["needDeployment"] = setupConfig.NeedDeployment.ToString(),
- ["webAppName"] = setupConfig.WebAppName ?? ""
- });
- }
-
- // Generate endpoint name with Azure Bot Service constraints (4-42 chars)
- var baseEndpointName = $"{setupConfig.WebAppName}-endpoint";
- endpointName = EndpointHelper.GetEndpointName(baseEndpointName);
-
- // Construct messaging endpoint URL from web app name
- messagingEndpoint = $"https://{setupConfig.WebAppName}.azurewebsites.net/api/messages";
- }
- else // Non-Azure hosting
- {
- // No deployment – use the provided MessagingEndpoint
- if (string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint))
- {
- logger.LogError("MessagingEndpoint must be provided in a365.config.json for non-Azure hosting.");
- throw new SetupValidationException(
- issueDescription: "Messaging endpoint is required for messaging endpoint registration.",
- errorDetails: new List
- {
- "needDeployment is set to 'no', but MessagingEndpoint was not provided in a365.config.json."
- },
- mitigationSteps: new List
- {
- "Open your a365.config.json file.",
- "If you want the CLI to deploy an Azure Web App, set \"needDeployment\": \"yes\" and provide \"webAppName\".",
- "If your agent is hosted elsewhere, keep \"needDeployment\": \"no\" and add a \"MessagingEndpoint\" with a valid HTTPS URL (e.g. \"https://your-host/api/messages\").",
- "Re-run 'a365 setup'."
- }
- );
- }
-
- if (!Uri.TryCreate(setupConfig.MessagingEndpoint, UriKind.Absolute, out var uri) ||
- uri.Scheme != Uri.UriSchemeHttps)
- {
- logger.LogError("MessagingEndpoint must be a valid HTTPS URL. Current value: {Endpoint}",
- setupConfig.MessagingEndpoint);
- throw new SetupValidationException("MessagingEndpoint must be a valid HTTPS URL.");
- }
-
- messagingEndpoint = setupConfig.MessagingEndpoint;
-
- // Derive endpoint name from host when there's no WebAppName
- var hostPart = uri.Host.Replace('.', '-');
- var baseEndpointName = $"{hostPart}-endpoint";
- endpointName = EndpointHelper.GetEndpointName(baseEndpointName);
-
- }
-
- if (endpointName.Length < 4)
- {
- logger.LogError("Bot endpoint name '{EndpointName}' is too short (must be at least 4 characters)", endpointName);
- throw new SetupValidationException($"Bot endpoint name '{endpointName}' is too short (must be at least 4 characters)");
- }
-
- logger.LogInformation(" - Registering blueprint messaging endpoint");
- logger.LogInformation(" * Endpoint Name: {EndpointName}", endpointName);
- logger.LogInformation(" * Messaging Endpoint: {Endpoint}", messagingEndpoint);
- logger.LogInformation(" * Using Agent Blueprint ID: {AgentBlueprintId}", setupConfig.AgentBlueprintId);
-
- var endpointRegistered = await botConfigurator.CreateEndpointWithAgentBlueprintAsync(
- endpointName: endpointName,
- location: setupConfig.Location,
- messagingEndpoint: messagingEndpoint,
- agentDescription: "Agent 365 messaging endpoint for automated interactions",
- agentBlueprintId: setupConfig.AgentBlueprintId);
-
- if (!endpointRegistered)
- {
- logger.LogError("Failed to register blueprint messaging endpoint");
- throw new SetupValidationException("Blueprint messaging endpoint registration failed");
- }
- // Update Agent365Config state properties
- setupConfig.BotId = setupConfig.AgentBlueprintId;
- setupConfig.BotMsaAppId = setupConfig.AgentBlueprintId;
- setupConfig.BotMessagingEndpoint = messagingEndpoint;
-
- }
-
///
- /// Ensure OAuth2 permission grants are set from blueprint to MCP server
+ /// Setup command - Agent 365 environment setup with granular subcommands
+ /// Supports permission-based workflow: infrastructure -> blueprint -> permissions -> endpoint
///
- private static async Task EnsureMcpOauth2PermissionGrantsAsync(
- GraphApiService graph,
- Agent365Config config,
- string[] scopes,
- ILogger logger,
- CancellationToken ct = default)
+ public class SetupCommand
{
- if (string.IsNullOrWhiteSpace(config.AgentBlueprintId))
- throw new SetupValidationException("AgentBlueprintId (appId) is required.");
-
- var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, config.AgentBlueprintId, ct);
- if (string.IsNullOrWhiteSpace(blueprintSpObjectId))
- {
- throw new SetupValidationException($"Blueprint Service Principal not found for appId {config.AgentBlueprintId}. " +
- "The service principal may not have propagated yet. Wait a few minutes and retry.");
- }
-
- var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment);
- var Agent365ToolsSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct);
- if (string.IsNullOrWhiteSpace(Agent365ToolsSpObjectId))
- {
- throw new SetupValidationException($"Agent 365 Tools Service Principal not found for appId {resourceAppId}. " +
- $"Ensure the Agent 365 Tools application is available in your tenant for environment: {config.Environment}");
- }
-
- logger.LogInformation(" - OAuth2 grant: client {ClientId} to resource {ResourceId} scopes [{Scopes}]",
- blueprintSpObjectId, Agent365ToolsSpObjectId, string.Join(' ', scopes));
-
- var response = await graph.CreateOrUpdateOauth2PermissionGrantAsync(
- config.TenantId, blueprintSpObjectId, Agent365ToolsSpObjectId, scopes, ct);
-
- if (!response)
- {
- throw new SetupValidationException(
- $"Failed to create/update OAuth2 permission grant from blueprint {config.AgentBlueprintId} to Agent 365 Tools {resourceAppId}. " +
- "This may be due to insufficient permissions. Ensure you have DelegatedPermissionGrant.ReadWrite.All or Application.ReadWrite.All permissions.");
+ public static Command CreateCommand(
+ ILogger logger,
+ IConfigService configService,
+ CommandExecutor executor,
+ DeploymentService deploymentService,
+ IBotConfigurator botConfigurator,
+ IAzureValidator azureValidator,
+ AzureWebAppCreator webAppCreator,
+ PlatformDetector platformDetector,
+ GraphApiService graphApiService)
+ {
+ var command = new Command("setup",
+ "Set up your Agent 365 environment with granular control over each step\n\n" +
+ "Recommended execution order:\n" +
+ " 1. a365 setup infrastructure (or skip if infrastructure exists)\n" +
+ " 2. a365 setup blueprint\n" +
+ " 3. a365 setup permissions mcp\n" +
+ " 4. a365 setup permissions bot\n" +
+ " 5. a365 setup endpoint\n\n" +
+ "Or run all steps at once:\n" +
+ " a365 setup all # Full setup (includes infrastructure)\n" +
+ " a365 setup all --skip-infrastructure # Skip infrastructure if it already exists");
+
+ // Add subcommands
+ command.AddCommand(InfrastructureSubcommand.CreateCommand(
+ logger, configService, azureValidator, webAppCreator, platformDetector, executor));
+
+ command.AddCommand(BlueprintSubcommand.CreateCommand(
+ logger, configService, executor, azureValidator, webAppCreator, platformDetector));
+
+ command.AddCommand(PermissionsSubcommand.CreateCommand(
+ logger, configService, executor, graphApiService));
+
+ command.AddCommand(EndpointSubcommand.CreateCommand(
+ logger, configService, botConfigurator, platformDetector));
+
+ command.AddCommand(AllSubcommand.CreateCommand(
+ logger, configService, executor, botConfigurator, azureValidator, webAppCreator, platformDetector, graphApiService));
+
+ return command;
}
}
-
- ///
- /// Ensure inheritable permissions are set from blueprint to MCP server
- ///
- private static async Task EnsureMcpInheritablePermissionsAsync(
- GraphApiService graph,
- Agent365Config config,
- string[] scopes,
- ILogger logger,
- CancellationToken ct = default)
- {
- if (string.IsNullOrWhiteSpace(config.AgentBlueprintId))
- throw new SetupValidationException("AgentBlueprintId (appId) is required.");
-
- var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment);
-
- logger.LogInformation(" - Inheritable permissions: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]",
- config.AgentBlueprintId, resourceAppId, string.Join(' ', scopes));
-
- var (ok, alreadyExists, err) = await graph.SetInheritablePermissionsAsync(
- config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, new List() { "AgentIdentityBlueprint.ReadWrite.All" }, ct);
-
- if (!ok && !alreadyExists)
- {
- config.InheritanceConfigured = false;
- config.InheritanceConfigError = err;
- throw new SetupValidationException($"Failed to set inheritable permissions: {err}. " +
- "Ensure you have Application.ReadWrite.All permissions and the blueprint supports inheritable permissions.");
- }
-
- config.InheritanceConfigured = true;
- config.InheritablePermissionsAlreadyExist = alreadyExists;
- config.InheritanceConfigError = null;
- }
-
- ///
- /// Display comprehensive setup summary showing what succeeded and what failed
- ///
- private static void DisplaySetupSummary(SetupResults results, ILogger logger)
- {
- logger.LogInformation("");
- logger.LogInformation("==========================================");
- logger.LogInformation("Setup Summary");
- logger.LogInformation("==========================================");
-
- // Show what succeeded
- logger.LogInformation("Completed Steps:");
- if (results.BlueprintCreated)
- {
- logger.LogInformation(" [OK] Agent blueprint created (Blueprint ID: {BlueprintId})", results.BlueprintId ?? "unknown");
- }
- if (results.McpPermissionsConfigured)
- logger.LogInformation(" [OK] MCP server permissions configured");
- if (results.InheritablePermissionsConfigured)
- logger.LogInformation(" [OK] Inheritable permissions configured");
- if (results.BotApiPermissionsConfigured)
- logger.LogInformation(" [OK] Messaging Bot API permissions configured");
- if (results.MessagingEndpointRegistered)
- logger.LogInformation(" [OK] Messaging endpoint registered");
-
- // Show what failed
- if (results.Errors.Count > 0)
- {
- logger.LogInformation("");
- logger.LogInformation("Failed Steps:");
- foreach (var error in results.Errors)
- {
- logger.LogInformation(" [FAILED] {Error}", error);
- }
- }
-
- // Show warnings
- if (results.Warnings.Count > 0)
- {
- logger.LogInformation("");
- logger.LogInformation("Warnings:");
- foreach (var warning in results.Warnings)
- {
- logger.LogInformation(" [WARN] {Warning}", warning);
- }
- }
-
- logger.LogInformation("");
-
- // Overall status
- if (results.HasErrors)
- {
- logger.LogWarning("Setup completed with errors");
- logger.LogInformation("");
- logger.LogInformation("Recovery Actions:");
-
- if (!results.InheritablePermissionsConfigured)
- {
- logger.LogInformation(" - Inheritable Permissions: Refer to Agent 365 CLI documentation for manual configuration");
- }
-
- if (!results.McpPermissionsConfigured)
- {
- logger.LogInformation(" - MCP Permissions: Refer to Agent 365 CLI documentation for manual configuration");
- }
-
- if (!results.BotApiPermissionsConfigured)
- {
- logger.LogInformation(" - Bot API Permissions: Refer to Agent 365 CLI documentation for manual configuration");
- }
-
- if (!results.MessagingEndpointRegistered)
- {
- logger.LogInformation(" - Messaging Endpoint: Refer to Agent 365 CLI documentation for manual configuration");
- }
- }
- else if (results.HasWarnings)
- {
- logger.LogInformation("Setup completed successfully with warnings");
- logger.LogInformation("Review warnings above and take action if needed");
- }
- else
- {
- logger.LogInformation("Setup completed successfully");
- logger.LogInformation("All components configured correctly");
- }
-
- logger.LogInformation("==========================================");
- }
-}
-
-///
-/// Tracks the results of each setup step for summary reporting
-///
-internal class SetupResults
-{
- public bool BlueprintCreated { get; set; }
- public string? BlueprintId { get; set; }
- public bool McpPermissionsConfigured { get; set; }
- public bool BotApiPermissionsConfigured { get; set; }
- public bool MessagingEndpointRegistered { get; set; }
- public bool InheritablePermissionsConfigured { get; set; }
-
- public List Errors { get; } = new();
- public List Warnings { get; } = new();
-
- public bool HasErrors => Errors.Count > 0;
- public bool HasWarnings => Warnings.Count > 0;
}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs
new file mode 100644
index 00000000..debdcd7a
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs
@@ -0,0 +1,291 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Agents.A365.DevTools.Cli.Exceptions;
+using Microsoft.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Extensions.Logging;
+using System.CommandLine;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands;
+
+///
+/// All subcommand - Runs complete setup (all steps in sequence)
+/// Orchestrates individual subcommand implementations
+/// Required permissions:
+/// - Azure Subscription Contributor/Owner (for infrastructure and endpoint)
+/// - Agent ID Developer role (for blueprint creation)
+/// - Global Administrator (for permission grants and admin consent)
+///
+internal static class AllSubcommand
+{
+ public static Command CreateCommand(
+ ILogger logger,
+ IConfigService configService,
+ CommandExecutor executor,
+ IBotConfigurator botConfigurator,
+ IAzureValidator azureValidator,
+ AzureWebAppCreator webAppCreator,
+ PlatformDetector platformDetector,
+ GraphApiService graphApiService)
+ {
+ var command = new Command("all",
+ "Run complete Agent 365 setup (all steps in sequence)\n" +
+ "Includes: Infrastructure + Blueprint + Permissions + Endpoint\n\n" +
+ "Minimum required permissions (Global Administrator has all of these):\n" +
+ " - Azure Subscription Contributor (for infrastructure and endpoint)\n" +
+ " - Agent ID Developer role (for blueprint creation)\n" +
+ " - Global Administrator (for permission grants and admin consent)\n\n");
+
+ var configOption = new Option(
+ ["--config", "-c"],
+ getDefaultValue: () => new FileInfo("a365.config.json"),
+ description: "Configuration file path");
+
+ var verboseOption = new Option(
+ ["--verbose", "-v"],
+ description: "Show detailed output");
+
+ var dryRunOption = new Option(
+ "--dry-run",
+ description: "Show what would be done without executing");
+
+ var skipInfrastructureOption = new Option(
+ "--skip-infrastructure",
+ description: "Skip Azure infrastructure creation (use if infrastructure already exists)\n" +
+ "This will still create: Blueprint + Permissions + Endpoint");
+
+ command.AddOption(configOption);
+ command.AddOption(verboseOption);
+ command.AddOption(dryRunOption);
+ command.AddOption(skipInfrastructureOption);
+
+ command.SetHandler(async (config, verbose, dryRun, skipInfrastructure) =>
+ {
+ if (dryRun)
+ {
+ logger.LogInformation("DRY RUN: Complete Agent 365 Setup");
+ logger.LogInformation("This would execute the following operations:");
+
+ if (!skipInfrastructure)
+ {
+ logger.LogInformation(" 1. Create Azure infrastructure");
+ }
+ else
+ {
+ logger.LogInformation(" 1. [SKIPPED] Azure infrastructure (--skip-infrastructure flag used)");
+ }
+
+ logger.LogInformation(" 2. Create agent blueprint (Entra ID application)");
+ logger.LogInformation(" 3. Configure MCP server permissions");
+ logger.LogInformation(" 4. Configure Bot API permissions");
+ logger.LogInformation(" 5. Register blueprint messaging endpoint and sync project settings");
+ logger.LogInformation("No actual changes will be made.");
+ return;
+ }
+
+ logger.LogInformation("Agent 365 Setup");
+ logger.LogInformation("Running all setup steps...");
+
+ if (skipInfrastructure)
+ {
+ logger.LogInformation("NOTE: Skipping infrastructure creation (--skip-infrastructure flag used)");
+ }
+
+ logger.LogInformation("");
+
+ var setupResults = new SetupResults();
+
+ try
+ {
+ // Load configuration
+ var setupConfig = await configService.LoadAsync(config.FullName);
+
+ // Validate Azure authentication
+ if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId))
+ {
+ Environment.Exit(1);
+ }
+
+ logger.LogInformation("");
+
+ var generatedConfigPath = Path.Combine(
+ config.DirectoryName ?? Environment.CurrentDirectory,
+ "a365.generated.config.json");
+
+ // Step 1: Infrastructure (optional)
+ try
+ {
+ logger.LogInformation("Step 1:");
+ logger.LogInformation("");
+
+ bool setupInfra = await InfrastructureSubcommand.CreateInfrastructureImplementationAsync(
+ logger,
+ config.FullName,
+ generatedConfigPath,
+ executor,
+ platformDetector,
+ setupConfig.NeedDeployment,
+ skipInfrastructure,
+ CancellationToken.None);
+
+ setupResults.InfrastructureCreated = skipInfrastructure ? false : setupInfra;
+ }
+ catch (Exception infraEx)
+ {
+ setupResults.InfrastructureCreated = false;
+ setupResults.Errors.Add($"Infrastructure: {infraEx.Message}");
+ logger.LogError(infraEx, "Failed to create infrastructure: {Message}", infraEx.Message);
+ throw;
+ }
+
+ // Step 2: Blueprint
+ logger.LogInformation("");
+ logger.LogInformation("Step 2:");
+ logger.LogInformation("");
+
+ try
+ {
+ var blueprintCreated = await BlueprintSubcommand.CreateBlueprintImplementationAsync(
+ setupConfig,
+ config,
+ executor,
+ azureValidator,
+ logger,
+ skipInfrastructure,
+ true);
+
+ setupResults.BlueprintCreated = blueprintCreated;
+
+ if (blueprintCreated)
+ {
+ // CRITICAL: Wait for file system to ensure config file is fully written
+ // Blueprint creation writes directly to disk and may not be immediately readable
+ logger.LogInformation("Ensuring configuration file is synchronized...");
+ await Task.Delay(2000); // 2 second delay to ensure file write is complete
+
+ // Reload config to get blueprint ID
+ // Use full path to ensure we're reading from the correct location
+ var fullConfigPath = Path.GetFullPath(config.FullName);
+ setupConfig = await configService.LoadAsync(fullConfigPath);
+ setupResults.BlueprintId = setupConfig.AgentBlueprintId;
+
+ // Validate blueprint ID was properly saved
+ if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId))
+ {
+ throw new SetupValidationException(
+ "Blueprint creation completed but AgentBlueprintId was not saved to configuration. " +
+ "This is required for the next steps (MCP permissions, Bot permissions, and endpoint registration).");
+ }
+ }
+ }
+ catch (Exception blueprintEx)
+ {
+ setupResults.BlueprintCreated = false;
+ setupResults.Errors.Add($"Blueprint: {blueprintEx.Message}");
+ logger.LogError(blueprintEx, "Failed to create blueprint: {Message}", blueprintEx.Message);
+ throw;
+ }
+
+ // Step 3: MCP Permissions
+ logger.LogInformation("");
+ logger.LogInformation("Step 3:");
+ logger.LogInformation("");
+
+ try
+ {
+ bool mcpPermissionSetup = await PermissionsSubcommand.ConfigureMcpPermissionsAsync(
+ config.FullName,
+ logger,
+ configService,
+ executor,
+ graphApiService,
+ setupConfig,
+ true);
+
+ setupResults.McpPermissionsConfigured = mcpPermissionSetup;
+ if (mcpPermissionSetup)
+ {
+ setupResults.InheritablePermissionsConfigured = setupConfig.InheritanceConfigured;
+ }
+ }
+ catch (Exception mcpPermEx)
+ {
+ setupResults.McpPermissionsConfigured = false;
+ setupResults.Errors.Add($"MCP Permissions: {mcpPermEx.Message}");
+ logger.LogWarning("Setup will continue, but MCP server permissions must be configured manually");
+ }
+
+ // Step 4: Bot API Permissions
+
+ logger.LogInformation("");
+ logger.LogInformation("Step 4:");
+ logger.LogInformation("");
+
+ try
+ {
+ bool botPermissionSetup = await PermissionsSubcommand.ConfigureBotPermissionsAsync(
+ config.FullName,
+ logger,
+ configService,
+ executor,
+ setupConfig,
+ graphApiService,
+ true);
+
+ setupResults.BotApiPermissionsConfigured = botPermissionSetup;
+ }
+ catch (Exception botPermEx)
+ {
+ setupResults.BotApiPermissionsConfigured = false;
+ setupResults.Errors.Add($"Bot API Permissions: {botPermEx.Message}");
+ logger.LogWarning("Setup will continue, but Bot API permissions must be configured manually");
+ }
+
+ // Step 5: Register endpoint and sync
+ logger.LogInformation("");
+ logger.LogInformation("Step 5:");
+ logger.LogInformation("");
+
+ try
+ {
+ await EndpointSubcommand.RegisterEndpointAndSyncAsync(
+ config.FullName,
+ logger,
+ configService,
+ botConfigurator,
+ platformDetector);
+
+ setupResults.MessagingEndpointRegistered = true;
+ logger.LogInformation("Blueprint messaging endpoint registered successfully");
+ }
+ catch (Exception endpointEx)
+ {
+ setupResults.MessagingEndpointRegistered = false;
+ setupResults.Errors.Add($"Messaging endpoint: {endpointEx.Message}");
+ logger.LogError("Failed to register messaging endpoint: {Message}", endpointEx.Message);
+ }
+
+ // Display verification info and summary
+ logger.LogInformation("");
+ await SetupHelpers.DisplayVerificationInfoAsync(config, logger);
+ SetupHelpers.DisplaySetupSummary(setupResults, logger);
+ }
+ catch (Agent365Exception ex)
+ {
+ ExceptionHandler.HandleAgent365Exception(ex);
+ Environment.Exit(1);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Setup failed: {Message}", ex.Message);
+ throw;
+ }
+ }, configOption, verboseOption, dryRunOption, skipInfrastructureOption);
+
+ return command;
+ }
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs
new file mode 100644
index 00000000..aa63c959
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs
@@ -0,0 +1,950 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Core;
+using Azure.Identity;
+using Microsoft.Agents.A365.DevTools.Cli.Constants;
+using Microsoft.Agents.A365.DevTools.Cli.Exceptions;
+using Microsoft.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;
+using Microsoft.Extensions.Logging;
+using Microsoft.Graph;
+using System.CommandLine;
+using System.Net.Http.Headers;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands;
+
+///
+/// Blueprint subcommand - Creates agent blueprint (Entra ID application)
+/// Required Permissions: Agent ID Developer role
+/// COMPLETE IMPLEMENTATION of A365SetupRunner Phase 2 blueprint creation
+///
+internal static class BlueprintSubcommand
+{
+ private const string MicrosoftGraphCommandLineToolsAppId = "14d82eec-204b-4c2f-b7e8-296a70dab67e";
+
+ public static Command CreateCommand(
+ ILogger logger,
+ IConfigService configService,
+ CommandExecutor executor,
+ IAzureValidator azureValidator,
+ AzureWebAppCreator webAppCreator,
+ PlatformDetector platformDetector)
+ {
+ var command = new Command("blueprint",
+ "Create agent blueprint (Entra ID application registration)\n" +
+ "Minimum required permissions: Agent ID Developer role\n");
+
+ var configOption = new Option(
+ ["--config", "-c"],
+ getDefaultValue: () => new FileInfo("a365.config.json"),
+ description: "Configuration file path");
+
+ var verboseOption = new Option(
+ ["--verbose", "-v"],
+ description: "Show detailed output");
+
+ var dryRunOption = new Option(
+ "--dry-run",
+ description: "Show what would be done without executing");
+
+ command.AddOption(configOption);
+ command.AddOption(verboseOption);
+ command.AddOption(dryRunOption);
+
+ command.SetHandler(async (config, verbose, dryRun) =>
+ {
+ var setupConfig = await configService.LoadAsync(config.FullName);
+
+ if (dryRun)
+ {
+ logger.LogInformation("DRY RUN: Create Agent Blueprint");
+ logger.LogInformation("Would create Entra ID application:");
+ logger.LogInformation(" - Display Name: {DisplayName}", setupConfig.AgentBlueprintDisplayName);
+ logger.LogInformation(" - Tenant: {TenantId}", setupConfig.TenantId);
+ logger.LogInformation(" - Would request admin consent for Graph and Connectivity APIs");
+ return;
+ }
+
+ await CreateBlueprintImplementationAsync(
+ setupConfig,
+ config,
+ executor,
+ azureValidator,
+ logger,
+ false,
+ false);
+
+ }, configOption, verboseOption, dryRunOption);
+
+ return command;
+ }
+
+ public static async Task CreateBlueprintImplementationAsync(
+ Models.Agent365Config setupConfig,
+ FileInfo config,
+ CommandExecutor executor,
+ IAzureValidator azureValidator,
+ ILogger logger,
+ bool skipInfrastructure,
+ bool isSetupAll,
+ CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("");
+ logger.LogInformation("==> Creating Agent Blueprint");
+
+ // Validate Azure authentication
+ if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId))
+ {
+ return false;
+ }
+
+ var generatedConfigPath = Path.Combine(
+ config.DirectoryName ?? Environment.CurrentDirectory,
+ "a365.generated.config.json");
+
+ // Load existing generated config (for MSI Principal ID)
+ JsonObject generatedConfig = new JsonObject();
+ string? principalId = null;
+
+ if (File.Exists(generatedConfigPath))
+ {
+ try
+ {
+ generatedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath))?.AsObject() ?? new JsonObject();
+
+ if (generatedConfig.TryGetPropertyValue("managedIdentityPrincipalId", out var existingPrincipalId))
+ {
+ principalId = existingPrincipalId?.GetValue();
+ logger.LogInformation("Found existing Managed Identity Principal ID: {Id}", principalId ?? "(none)");
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning("Could not load existing config: {Message}. Starting fresh.", ex.Message);
+ }
+ }
+ else
+ {
+ logger.LogInformation("No existing configuration found - blueprint will be created without managed identity");
+ }
+
+ // Create required services
+ var delegatedConsentService = new DelegatedConsentService(
+ LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(),
+ new GraphApiService(
+ LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(),
+ executor));
+
+ var graphService = new GraphApiService(
+ LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(),
+ executor);
+
+ // ========================================================================
+ // Phase 2.1: Delegated Consent
+ // ========================================================================
+
+ logger.LogInformation("");
+ logger.LogInformation("==> Creating Agent Blueprint");
+
+ // CRITICAL: Grant AgentApplication.Create permission BEFORE creating blueprint
+ // This replaces the PowerShell call to DelegatedAgentApplicationCreateConsent.ps1
+ logger.LogInformation("");
+ logger.LogInformation("==> Ensuring AgentApplication.Create Permission");
+ logger.LogInformation("This permission is required to create Agent Blueprints");
+
+ var consentResult = await EnsureDelegatedConsentWithRetriesAsync(
+ delegatedConsentService,
+ setupConfig.TenantId,
+ logger);
+
+ if (!consentResult)
+ {
+ logger.LogError("Failed to ensure AgentApplication.Create permission after multiple attempts");
+ return false;
+ }
+
+ // ========================================================================
+ // Phase 2.2: Create Blueprint
+ // ========================================================================
+
+ logger.LogInformation("");
+ logger.LogInformation("==> Creating Agent Blueprint Application");
+
+ // Validate required config
+ if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintDisplayName))
+ {
+ throw new InvalidOperationException("agentBlueprintDisplayName missing in configuration");
+ }
+
+ var useManagedIdentity = (setupConfig.NeedDeployment && !skipInfrastructure) || skipInfrastructure;
+
+ var blueprintResult = await CreateAgentBlueprintAsync(
+ logger,
+ executor,
+ setupConfig.TenantId,
+ setupConfig.AgentBlueprintDisplayName,
+ setupConfig.AgentIdentityDisplayName,
+ principalId,
+ useManagedIdentity,
+ generatedConfig,
+ setupConfig,
+ cancellationToken);
+
+ if (!blueprintResult.success)
+ {
+ logger.LogError("Failed to create agent blueprint");
+ return false;
+ }
+
+ var blueprintAppId = blueprintResult.appId;
+ var blueprintObjectId = blueprintResult.objectId;
+
+ logger.LogInformation("Agent Blueprint Details:");
+ logger.LogInformation(" - Display Name: {Name}", setupConfig.AgentBlueprintDisplayName);
+ logger.LogInformation(" - App ID: {Id}", blueprintAppId);
+ logger.LogInformation(" - Object ID: {Id}", blueprintObjectId);
+ logger.LogInformation(" - Identifier URI: api://{Id}", blueprintAppId);
+
+ // Convert to camelCase and save
+ var camelCaseConfig = new JsonObject
+ {
+ ["managedIdentityPrincipalId"] = generatedConfig["managedIdentityPrincipalId"]?.DeepClone(),
+ ["agentBlueprintId"] = blueprintAppId,
+ ["agentBlueprintObjectId"] = blueprintObjectId,
+ ["displayName"] = setupConfig.AgentBlueprintDisplayName,
+ ["servicePrincipalId"] = blueprintResult.servicePrincipalId,
+ ["identifierUri"] = $"api://{blueprintAppId}",
+ ["tenantId"] = setupConfig.TenantId,
+ ["consentUrlGraph"] = generatedConfig["consentUrlGraph"]?.DeepClone(),
+ ["consentUrlConnectivity"] = generatedConfig["consentUrlConnectivity"]?.DeepClone(),
+ ["consent1Granted"] = generatedConfig["consent1Granted"]?.DeepClone(),
+ ["consent2Granted"] = generatedConfig["consent2Granted"]?.DeepClone(),
+
+ };
+
+ await File.WriteAllTextAsync(generatedConfigPath, camelCaseConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), cancellationToken);
+ generatedConfig = camelCaseConfig;
+
+ // ========================================================================
+ // Phase 2.5: Create Client Secret (logging handled by method)
+ // ========================================================================
+
+ logger.LogInformation("");
+ logger.LogInformation("==> Creating Client Secret for Agent Blueprint");
+
+ await CreateBlueprintClientSecretAsync(
+ blueprintObjectId!,
+ blueprintAppId!,
+ generatedConfig,
+ generatedConfigPath,
+ graphService,
+ setupConfig,
+ logger);
+
+ // Final summary
+ logger.LogInformation("");
+ logger.LogInformation("Agent blueprint created successfully");
+ logger.LogInformation("Generated config saved: {Path}", generatedConfigPath);
+ logger.LogInformation("");
+ if (!isSetupAll)
+ {
+ logger.LogInformation("Next steps:");
+ logger.LogInformation(" 1. Run 'a365 setup permissions mcp' to configure MCP permissions");
+ logger.LogInformation(" 2. Run 'a365 setup permissions bot' to configure Bot API permissions");
+ logger.LogInformation(" 3. Run 'a365 setup endpoint' to register messaging endpoint");
+ }
+
+ return true;
+ }
+
+ ///
+ /// Ensures AgentApplication.Create permission with retry logic (3 attempts, 5-second delays)
+ /// Used by: BlueprintSubcommand and A365SetupRunner Phase 2.1
+ ///
+ public static async Task EnsureDelegatedConsentWithRetriesAsync(
+ DelegatedConsentService delegatedConsentService,
+ string tenantId,
+ ILogger logger,
+ CancellationToken cancellationToken = default)
+ {
+ const int maxRetries = 3;
+ const int retryDelaySeconds = 5;
+
+ for (int attempt = 1; attempt <= maxRetries; attempt++)
+ {
+ try
+ {
+ if (attempt > 1)
+ {
+ logger.LogInformation("Retry attempt {Attempt} of {MaxRetries} for delegated consent", attempt, maxRetries);
+ await Task.Delay(TimeSpan.FromSeconds(retryDelaySeconds), cancellationToken);
+ }
+
+ var success = await delegatedConsentService.EnsureAgentApplicationCreateConsentAsync(
+ MicrosoftGraphCommandLineToolsAppId,
+ tenantId,
+ cancellationToken);
+
+ if (success)
+ {
+ logger.LogInformation("Successfully ensured delegated application consent on attempt {Attempt}", attempt);
+ return true;
+ }
+
+ logger.LogWarning("Consent attempt {Attempt} returned false", attempt);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Consent attempt {Attempt} failed: {Message}", attempt, ex.Message);
+
+ if (attempt == maxRetries)
+ {
+ logger.LogError("All retry attempts exhausted for delegated consent");
+ logger.LogError("Common causes:");
+ logger.LogError(" 1. Insufficient permissions - You need Application.ReadWrite.All and DelegatedPermissionGrant.ReadWrite.All");
+ logger.LogError(" 2. Not a Global Administrator or similar privileged role");
+ logger.LogError(" 3. Azure CLI authentication expired - Run 'az login' and retry");
+ logger.LogError(" 4. Network connectivity issues");
+ return false;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Creates Agent Blueprint application using Graph API
+ /// Used by: BlueprintSubcommand and A365SetupRunner Phase 2.2
+ /// Returns: (success, appId, objectId, servicePrincipalId)
+ ///
+ public static async Task<(bool success, string? appId, string? objectId, string? servicePrincipalId)> CreateAgentBlueprintAsync(
+ ILogger logger,
+ CommandExecutor executor,
+ string tenantId,
+ string displayName,
+ string? agentIdentityDisplayName,
+ string? managedIdentityPrincipalId,
+ bool useManagedIdentity,
+ JsonObject generatedConfig,
+ Models.Agent365Config setupConfig,
+ CancellationToken ct)
+ {
+ try
+ {
+ logger.LogInformation("Creating Agent Blueprint using Microsoft Graph SDK...");
+
+ GraphServiceClient graphClient;
+ try
+ {
+ graphClient = await GetAuthenticatedGraphClientAsync(logger, tenantId, ct);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to get authenticated Graph client: {Message}", ex.Message);
+ return (false, null, null, null);
+ }
+
+ // Get current user for sponsors field (mimics PowerShell script behavior)
+ string? sponsorUserId = null;
+ try
+ {
+ var me = await graphClient.Me.GetAsync(cancellationToken: ct);
+ if (me != null && !string.IsNullOrEmpty(me.Id))
+ {
+ sponsorUserId = me.Id;
+ logger.LogInformation("Current user: {DisplayName} <{UPN}>", me.DisplayName, me.UserPrincipalName);
+ logger.LogInformation("Sponsor: https://graph.microsoft.com/v1.0/users/{UserId}", sponsorUserId);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning("Could not retrieve current user for sponsors field: {Message}", ex.Message);
+ }
+
+ // Define the application manifest with @odata.type for Agent Identity Blueprint
+ var appManifest = new JsonObject
+ {
+ ["@odata.type"] = "Microsoft.Graph.AgentIdentityBlueprint", // CRITICAL: Required for Agent Blueprint type
+ ["displayName"] = displayName,
+ ["signInAudience"] = "AzureADMultipleOrgs" // Multi-tenant
+ };
+
+ // Add sponsors field if we have the current user (PowerShell script includes this)
+ if (!string.IsNullOrEmpty(sponsorUserId))
+ {
+ appManifest["sponsors@odata.bind"] = new JsonArray
+ {
+ $"https://graph.microsoft.com/v1.0/users/{sponsorUserId}"
+ };
+ }
+
+ // Create the application using Microsoft Graph SDK
+ using var httpClient = new HttpClient();
+ var graphToken = await GetTokenFromGraphClient(logger, graphClient, tenantId);
+ if (string.IsNullOrEmpty(graphToken))
+ {
+ logger.LogError("Failed to extract access token from Graph client");
+ return (false, null, null, null);
+ }
+
+ httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken);
+ httpClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual");
+ httpClient.DefaultRequestHeaders.Add("OData-Version", "4.0"); // Required for @odata.type
+
+ var createAppUrl = "https://graph.microsoft.com/beta/applications";
+
+ logger.LogInformation("Creating Agent Blueprint application...");
+ logger.LogInformation(" - Display Name: {DisplayName}", displayName);
+ if (!string.IsNullOrEmpty(sponsorUserId))
+ {
+ logger.LogInformation(" - Sponsor: User ID {UserId}", sponsorUserId);
+ }
+
+ var appResponse = await httpClient.PostAsync(
+ createAppUrl,
+ new StringContent(appManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
+ ct);
+
+ if (!appResponse.IsSuccessStatusCode)
+ {
+ var errorContent = await appResponse.Content.ReadAsStringAsync(ct);
+
+ // If sponsors field causes error (Bad Request 400), retry without it
+ if (appResponse.StatusCode == System.Net.HttpStatusCode.BadRequest &&
+ !string.IsNullOrEmpty(sponsorUserId))
+ {
+ logger.LogWarning("Agent Blueprint creation with sponsors failed (Bad Request). Retrying without sponsors...");
+
+ // Remove sponsors field and retry
+ appManifest.Remove("sponsors@odata.bind");
+
+ appResponse = await httpClient.PostAsync(
+ createAppUrl,
+ new StringContent(appManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
+ ct);
+
+ if (!appResponse.IsSuccessStatusCode)
+ {
+ errorContent = await appResponse.Content.ReadAsStringAsync(ct);
+ logger.LogError("Failed to create application (fallback): {Status} - {Error}", appResponse.StatusCode, errorContent);
+ return (false, null, null, null);
+ }
+ }
+ else
+ {
+ logger.LogError("Failed to create application: {Status} - {Error}", appResponse.StatusCode, errorContent);
+ return (false, null, null, null);
+ }
+ }
+
+ var appJson = await appResponse.Content.ReadAsStringAsync(ct);
+ var app = JsonNode.Parse(appJson)!.AsObject();
+ var appId = app["appId"]!.GetValue();
+ var objectId = app["id"]!.GetValue();
+
+ logger.LogInformation("Application created successfully");
+ logger.LogInformation(" - App ID: {AppId}", appId);
+ logger.LogInformation(" - Object ID: {ObjectId}", objectId);
+
+ // Wait for application propagation
+ const int maxRetries = 30;
+ const int delayMs = 4000;
+ bool appAvailable = false;
+ for (int i = 0; i < maxRetries; i++)
+ {
+ var checkResp = await httpClient.GetAsync($"https://graph.microsoft.com/v1.0/applications/{objectId}", ct);
+ if (checkResp.IsSuccessStatusCode)
+ {
+ appAvailable = true;
+ break;
+ }
+ logger.LogInformation("Waiting for application object to be available in directory (attempt {Attempt}/{Max})...", i + 1, maxRetries);
+ await Task.Delay(delayMs, ct);
+ }
+
+ if (!appAvailable)
+ {
+ logger.LogError("App object not available after creation. Aborting setup.");
+ return (false, null, null, null);
+ }
+
+ // Update application with identifier URI
+ var identifierUri = $"api://{appId}";
+ var patchAppUrl = $"https://graph.microsoft.com/v1.0/applications/{objectId}";
+ var patchBody = new JsonObject
+ {
+ ["identifierUris"] = new JsonArray { identifierUri }
+ };
+
+ var patchResponse = await httpClient.PatchAsync(
+ patchAppUrl,
+ new StringContent(patchBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
+ ct);
+
+ if (!patchResponse.IsSuccessStatusCode)
+ {
+ var patchError = await patchResponse.Content.ReadAsStringAsync(ct);
+ logger.LogInformation("Waiting for application propagation before setting identifier URI...");
+ logger.LogDebug("Identifier URI update deferred (propagation delay): {Error}", patchError);
+ }
+ else
+ {
+ logger.LogInformation("Identifier URI set to: {Uri}", identifierUri);
+ }
+
+ // Create service principal
+ logger.LogInformation("Creating service principal...");
+
+ var spManifest = new JsonObject
+ {
+ ["appId"] = appId
+ };
+
+ var createSpUrl = "https://graph.microsoft.com/v1.0/servicePrincipals";
+ var spResponse = await httpClient.PostAsync(
+ createSpUrl,
+ new StringContent(spManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
+ ct);
+
+ string? servicePrincipalId = null;
+ if (spResponse.IsSuccessStatusCode)
+ {
+ var spJson = await spResponse.Content.ReadAsStringAsync(ct);
+ var sp = JsonNode.Parse(spJson)!.AsObject();
+ servicePrincipalId = sp["id"]!.GetValue();
+ logger.LogInformation("Service principal created: {SpId}", servicePrincipalId);
+ }
+ else
+ {
+ var spError = await spResponse.Content.ReadAsStringAsync(ct);
+ logger.LogInformation("Waiting for application propagation before creating service principal...");
+ logger.LogDebug("Service principal creation deferred (propagation delay): {Error}", spError);
+ }
+
+ // Wait for service principal propagation
+ logger.LogInformation("Waiting 10 seconds to ensure Service Principal is fully propagated...");
+ await Task.Delay(10000, ct);
+
+ // Create Federated Identity Credential ONLY when MSI is relevant (if managed identity provided)
+ if (useManagedIdentity && !string.IsNullOrWhiteSpace(managedIdentityPrincipalId))
+ {
+ logger.LogInformation("Creating Federated Identity Credential for Managed Identity...");
+ var credentialName = $"{displayName.Replace(" ", "")}-MSI";
+
+ var ficSuccess = await CreateFederatedIdentityCredentialAsync(
+ tenantId,
+ objectId,
+ credentialName,
+ managedIdentityPrincipalId,
+ graphToken,
+ logger,
+ ct);
+
+ if (ficSuccess)
+ {
+ logger.LogInformation("Federated Identity Credential created successfully");
+ }
+ else
+ {
+ logger.LogWarning("Failed to create Federated Identity Credential");
+ }
+ }
+ else if (!useManagedIdentity)
+ {
+ logger.LogInformation("Skipping Federated Identity Credential creation (external hosting / no MSI configured)");
+ }
+ else
+ {
+ logger.LogInformation("Skipping Federated Identity Credential creation (no MSI Principal ID provided)");
+ }
+
+ // Request admin consent
+ logger.LogInformation("Requesting admin consent for application");
+
+ // Get application scopes from config (fallback to hardcoded defaults)
+ var applicationScopes = new List();
+
+ var appScopesFromConfig = setupConfig.AgentApplicationScopes;
+ if (appScopesFromConfig != null && appScopesFromConfig.Count > 0)
+ {
+ logger.LogInformation(" Found 'agentApplicationScopes' in typed config");
+ applicationScopes.AddRange(appScopesFromConfig);
+ }
+ else
+ {
+ logger.LogInformation(" 'agentApplicationScopes' not found in config, using hardcoded defaults");
+ applicationScopes.AddRange(ConfigConstants.DefaultAgentApplicationScopes);
+ }
+
+ // Final fallback (should not happen with proper defaults)
+ if (applicationScopes.Count == 0)
+ {
+ logger.LogWarning("No application scopes available, falling back to User.Read");
+ applicationScopes.Add("User.Read");
+ }
+
+ logger.LogInformation(" - Application scopes: {Scopes}", string.Join(", ", applicationScopes));
+
+ // Generate consent URLs for Graph and Connectivity
+ var applicationScopesJoined = string.Join(' ', applicationScopes);
+ var consentUrlGraph = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={appId}&scope={Uri.EscapeDataString(applicationScopesJoined)}&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123";
+ var consentUrlConnectivity = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={appId}&scope=0ddb742a-e7dc-4899-a31e-80e797ec7144/Connectivity.Connections.Read&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123";
+
+ logger.LogInformation("Opening browser for Graph API admin consent...");
+ TryOpenBrowser(consentUrlGraph);
+
+ var consent1Success = await AdminConsentHelper.PollAdminConsentAsync(executor, logger, appId, "Graph API Scopes", 180, 5, ct);
+
+ if (consent1Success)
+ {
+ logger.LogInformation("Graph API admin consent granted successfully!");
+ }
+ else
+ {
+ logger.LogWarning("Graph API admin consent may not have completed");
+ }
+
+ logger.LogInformation("");
+ logger.LogInformation("Opening browser for Connectivity admin consent...");
+ TryOpenBrowser(consentUrlConnectivity);
+
+ var consent2Success = await AdminConsentHelper.PollAdminConsentAsync(executor, logger, appId, "Connectivity Scope", 180, 5, ct);
+
+ if (consent2Success)
+ {
+ logger.LogInformation("Connectivity admin consent granted successfully!");
+ }
+ else
+ {
+ logger.LogWarning("Connectivity admin consent may not have completed");
+ }
+
+ // Save consent URLs and status to generated config
+ generatedConfig["consentUrlGraph"] = consentUrlGraph;
+ generatedConfig["consentUrlConnectivity"] = consentUrlConnectivity;
+ generatedConfig["consent1Granted"] = consent1Success;
+ generatedConfig["consent2Granted"] = consent2Success;
+
+ if (!consent1Success || !consent2Success)
+ {
+ logger.LogWarning("");
+ logger.LogWarning("One or more consents may not have been detected");
+ logger.LogWarning("The setup will continue, but you may need to grant consent manually.");
+ logger.LogWarning("Consent URL (Graph): {Url}", consentUrlGraph);
+ logger.LogWarning("Consent URL (Connectivity): {Url}", consentUrlConnectivity);
+ }
+
+ return (true, appId, objectId, servicePrincipalId);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to create agent blueprint: {Message}", ex.Message);
+ return (false, null, null, null);
+ }
+ }
+
+ ///
+ /// Extracts the access token from a GraphServiceClient for use in direct HTTP calls.
+ /// This uses InteractiveBrowserCredential directly which is simpler and more reliable.
+ ///
+ private static async Task GetTokenFromGraphClient(ILogger logger, GraphServiceClient graphClient, string tenantId)
+ {
+ try
+ {
+ // Use Azure.Identity to get the token directly
+ // This is cleaner and more reliable than trying to extract it from GraphServiceClient
+ var credential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions
+ {
+ TenantId = tenantId,
+ ClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e" // Microsoft Graph PowerShell app ID
+ });
+
+ var tokenRequestContext = new TokenRequestContext(new[] { "https://graph.microsoft.com/.default" });
+ var token = await credential.GetTokenAsync(tokenRequestContext, CancellationToken.None);
+
+ return token.Token;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to get access token");
+ return null;
+ }
+ }
+
+ ///
+ /// Creates and authenticates a GraphServiceClient using InteractiveGraphAuthService.
+ /// This common method consolidates the authentication logic used across multiple methods.
+ ///
+ private async static Task GetAuthenticatedGraphClientAsync(ILogger logger,string tenantId, CancellationToken ct)
+ {
+ logger.LogInformation("Authenticating to Microsoft Graph using interactive browser authentication...");
+ logger.LogInformation("IMPORTANT: Agent Blueprint operations require Application.ReadWrite.All permission.");
+ logger.LogInformation("This will open a browser window for interactive authentication.");
+ logger.LogInformation("Please sign in with a Global Administrator account.");
+ logger.LogInformation("");
+
+ // Use InteractiveGraphAuthService to get proper authentication
+ var interactiveAuth = new InteractiveGraphAuthService(
+ LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger());
+
+ try
+ {
+ var graphClient = await interactiveAuth.GetAuthenticatedGraphClientAsync(tenantId, ct);
+ logger.LogInformation("Successfully authenticated to Microsoft Graph");
+ return graphClient;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to authenticate to Microsoft Graph: {Message}", ex.Message);
+ logger.LogError("");
+ logger.LogError("TROUBLESHOOTING:");
+ logger.LogError("1. Ensure you are a Global Administrator or have Application.ReadWrite.All permission");
+ logger.LogError("2. The account must have already consented to these permissions");
+ logger.LogError("");
+ throw new InvalidOperationException($"Microsoft Graph authentication failed: {ex.Message}", ex);
+ }
+ }
+
+ 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
+ }
+ }
+
+ ///
+ /// Creates client secret for Agent Blueprint (Phase 2.5)
+ /// Used by: BlueprintSubcommand and A365SetupRunner
+ ///
+ public static async Task CreateBlueprintClientSecretAsync(
+ string blueprintObjectId,
+ string blueprintAppId,
+ JsonObject generatedConfig,
+ string generatedConfigPath,
+ GraphApiService graphService,
+ Models.Agent365Config setupConfig,
+ ILogger logger,
+ CancellationToken ct = default)
+ {
+ try
+ {
+ logger.LogInformation("Creating client secret for Agent Blueprint using Graph API...");
+
+ var graphToken = await graphService.GetGraphAccessTokenAsync(
+ generatedConfig["tenantId"]?.GetValue() ?? string.Empty, ct);
+
+ if (string.IsNullOrWhiteSpace(graphToken))
+ {
+ logger.LogError("Failed to acquire Graph API access token");
+ throw new InvalidOperationException("Cannot create client secret without Graph API token");
+ }
+
+ using var httpClient = new HttpClient();
+ httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken);
+
+ var secretBody = new JsonObject
+ {
+ ["passwordCredential"] = new JsonObject
+ {
+ ["displayName"] = "Agent 365 CLI Generated Secret",
+ ["endDateTime"] = DateTime.UtcNow.AddYears(2).ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
+ }
+ };
+
+ var addPasswordUrl = $"https://graph.microsoft.com/v1.0/applications/{blueprintObjectId}/addPassword";
+ var passwordResponse = await httpClient.PostAsync(
+ addPasswordUrl,
+ new StringContent(secretBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
+ ct);
+
+ if (!passwordResponse.IsSuccessStatusCode)
+ {
+ var errorContent = await passwordResponse.Content.ReadAsStringAsync(ct);
+ logger.LogError("Failed to create client secret: {Status} - {Error}", passwordResponse.StatusCode, errorContent);
+ throw new InvalidOperationException($"Failed to create client secret: {errorContent}");
+ }
+
+ var passwordJson = await passwordResponse.Content.ReadAsStringAsync(ct);
+ var passwordResult = JsonNode.Parse(passwordJson)!.AsObject();
+
+ var secretTextNode = passwordResult["secretText"];
+ if (secretTextNode == null || string.IsNullOrWhiteSpace(secretTextNode.GetValue()))
+ {
+ logger.LogError("Client secret text is empty in response");
+ throw new InvalidOperationException("Client secret creation returned empty secret");
+ }
+
+ var protectedSecret = ProtectSecret(secretTextNode.GetValue(), logger);
+
+ var isProtected = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+ generatedConfig["agentBlueprintClientSecret"] = protectedSecret;
+ generatedConfig["agentBlueprintClientSecretProtected"] = isProtected;
+ setupConfig.AgentBlueprintClientSecret = protectedSecret;
+ setupConfig.AgentBlueprintClientSecretProtected = isProtected;
+
+ await File.WriteAllTextAsync(
+ generatedConfigPath,
+ generatedConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }),
+ ct);
+
+ logger.LogInformation("Client secret created successfully!");
+ logger.LogInformation($" - Secret stored in generated config (encrypted: {isProtected})");
+ logger.LogWarning("IMPORTANT: The client secret has been stored in {Path}", generatedConfigPath);
+ logger.LogWarning("Keep this file secure and do not commit it to source control!");
+
+ if (!isProtected)
+ {
+ logger.LogWarning("WARNING: Secret encryption is only available on Windows. The secret is stored in plaintext.");
+ logger.LogWarning("Consider using environment variables or Azure Key Vault for production deployments.");
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to create client secret: {Message}", ex.Message);
+ logger.LogInformation("You can create a client secret manually:");
+ logger.LogInformation(" 1. Go to Azure Portal > App Registrations");
+ logger.LogInformation(" 2. Find your Agent Blueprint: {AppId}", blueprintAppId);
+ logger.LogInformation(" 3. Navigate to Certificates & secrets > Client secrets");
+ logger.LogInformation(" 4. Click 'New client secret' and save the value");
+ logger.LogInformation(" 5. Add it to {Path} as 'agentBlueprintClientSecret'", generatedConfigPath);
+ }
+ }
+
+ #region Private Helper Methods
+
+ private static async Task CreateFederatedIdentityCredentialAsync(
+ string tenantId,
+ string blueprintObjectId,
+ string credentialName,
+ string msiPrincipalId,
+ string graphToken,
+ ILogger logger,
+ CancellationToken ct)
+ {
+ const int maxRetries = 5;
+ const int initialDelayMs = 2000;
+
+ try
+ {
+ var federatedCredential = new JsonObject
+ {
+ ["name"] = credentialName,
+ ["issuer"] = $"https://login.microsoftonline.com/{tenantId}/v2.0",
+ ["subject"] = msiPrincipalId,
+ ["audiences"] = new JsonArray { "api://AzureADTokenExchange" }
+ };
+
+ using var httpClient = new HttpClient();
+ httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken);
+ httpClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual");
+
+ var urls = new []
+ {
+ $"https://graph.microsoft.com/beta/applications/{blueprintObjectId}/federatedIdentityCredentials",
+ $"https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/federatedIdentityCredentials"
+ };
+
+ string? lastError = null;
+
+ foreach (var url in urls)
+ {
+ for (int attempt = 1; attempt <= maxRetries; attempt++)
+ {
+ var response = await httpClient.PostAsync(
+ url,
+ new StringContent(federatedCredential.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
+ ct);
+
+ if (response.IsSuccessStatusCode)
+ {
+ logger.LogInformation(" - Credential Name: {Name}", credentialName);
+ logger.LogInformation(" - Issuer: https://login.microsoftonline.com/{TenantId}/v2.0", tenantId);
+ logger.LogInformation(" - Subject (MSI Principal ID): {MsiId}", msiPrincipalId);
+ return true;
+ }
+
+ var error = await response.Content.ReadAsStringAsync(ct);
+ lastError = error;
+
+ if ((error.Contains("Request_ResourceNotFound") || error.Contains("does not exist")) && attempt < maxRetries)
+ {
+ var delayMs = initialDelayMs * (int)Math.Pow(2, attempt - 1);
+ logger.LogWarning("Application object not yet propagated (attempt {Attempt}/{MaxRetries}). Retrying in {Delay}ms...",
+ attempt, maxRetries, delayMs);
+ await Task.Delay(delayMs, ct);
+ continue;
+ }
+
+ if (error.Contains("Agent Blueprints are not supported on the API version"))
+ {
+ logger.LogDebug("Standard endpoint not supported, trying Agent Blueprint-specific path...");
+ break;
+ }
+
+ logger.LogDebug("FIC creation failed with error: {Error}", error);
+ break;
+ }
+ }
+
+ logger.LogDebug("Failed to create federated identity credential after trying all endpoints: {Error}", lastError);
+ return false;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Exception creating federated identity credential: {Message}", ex.Message);
+ return false;
+ }
+ }
+
+ private static string ProtectSecret(string plaintext, ILogger logger)
+ {
+ if (string.IsNullOrWhiteSpace(plaintext))
+ {
+ return plaintext;
+ }
+
+ try
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ var plaintextBytes = System.Text.Encoding.UTF8.GetBytes(plaintext);
+ var protectedBytes = ProtectedData.Protect(
+ plaintextBytes,
+ optionalEntropy: null,
+ scope: DataProtectionScope.CurrentUser);
+
+ return Convert.ToBase64String(protectedBytes);
+ }
+ else
+ {
+ logger.LogWarning("DPAPI encryption not available on this platform. Secret will be stored in plaintext.");
+ return plaintext;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to encrypt secret, storing in plaintext: {Message}", ex.Message);
+ return plaintext;
+ }
+ }
+
+ #endregion
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/EndpointSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/EndpointSubcommand.cs
new file mode 100644
index 00000000..d2f6e530
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/EndpointSubcommand.cs
@@ -0,0 +1,145 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Agents.A365.DevTools.Cli.Helpers;
+using Microsoft.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Extensions.Logging;
+using System.CommandLine;
+using System.Text.Json;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands;
+
+///
+/// Endpoint subcommand - Registers blueprint messaging endpoint (Azure Bot Service)
+/// Required Permissions: Azure Subscription Contributor
+///
+internal static class EndpointSubcommand
+{
+ public static Command CreateCommand(
+ ILogger logger,
+ IConfigService configService,
+ IBotConfigurator botConfigurator,
+ PlatformDetector platformDetector)
+ {
+ var command = new Command("endpoint",
+ "Register blueprint messaging endpoint (Azure Bot Service)\n" +
+ "Minimum required permissions: Azure Subscription Contributor\n");
+
+ var configOption = new Option(
+ ["--config", "-c"],
+ getDefaultValue: () => new FileInfo("a365.config.json"),
+ description: "Configuration file path");
+
+ var verboseOption = new Option(
+ ["--verbose", "-v"],
+ description: "Show detailed output");
+
+ var dryRunOption = new Option(
+ "--dry-run",
+ description: "Show what would be done without executing");
+
+ command.AddOption(configOption);
+ command.AddOption(verboseOption);
+ command.AddOption(dryRunOption);
+
+ command.SetHandler(async (config, verbose, dryRun) =>
+ {
+ var setupConfig = await configService.LoadAsync(config.FullName);
+
+ if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId))
+ {
+ logger.LogError("Blueprint ID not found. Run 'a365 setup blueprint' first.");
+ Environment.Exit(1);
+ }
+
+ if (string.IsNullOrWhiteSpace(setupConfig.WebAppName))
+ {
+ logger.LogError("Web App Name not found. Run 'a365 setup infrastructure' first.");
+ Environment.Exit(1);
+ }
+
+ if (dryRun)
+ {
+ logger.LogInformation("DRY RUN: Register Messaging Endpoint");
+ logger.LogInformation("Would register Bot Service endpoint:");
+ logger.LogInformation(" - Endpoint Name: {Name}-endpoint", setupConfig.WebAppName);
+ logger.LogInformation(" - Messaging URL: https://{Name}.azurewebsites.net/api/messages", setupConfig.WebAppName);
+ logger.LogInformation(" - Blueprint ID: {Id}", setupConfig.AgentBlueprintId);
+ logger.LogInformation("Would sync generated configuration to project settings");
+ return;
+ }
+
+ await RegisterEndpointAndSyncAsync(
+ configPath: config.FullName,
+ logger: logger,
+ configService: configService,
+ botConfigurator: botConfigurator,
+ platformDetector: platformDetector);
+
+ // Display verification info and summary
+ await SetupHelpers.DisplayVerificationInfoAsync(config, logger);
+
+ }, configOption, verboseOption, dryRunOption);
+
+ return command;
+ }
+
+ #region Public Static Implementation Method (for AllSubcommand)
+
+ ///
+ /// Registers blueprint messaging endpoint and syncs project settings.
+ /// Public method that can be called by AllSubcommand.
+ ///
+ public static async Task RegisterEndpointAndSyncAsync(
+ string configPath,
+ ILogger logger,
+ IConfigService configService,
+ IBotConfigurator botConfigurator,
+ PlatformDetector platformDetector,
+ CancellationToken cancellationToken = default)
+ {
+ var setupConfig = await configService.LoadAsync(configPath);
+
+ logger.LogInformation("Registering blueprint messaging endpoint...");
+ logger.LogInformation("");
+
+ await SetupHelpers.RegisterBlueprintMessagingEndpointAsync(
+ setupConfig, logger, botConfigurator);
+
+
+ setupConfig.Completed = true;
+ setupConfig.CompletedAt = DateTime.UtcNow;
+
+ await configService.SaveStateAsync(setupConfig);
+
+ logger.LogInformation("");
+ logger.LogInformation("Blueprint messaging endpoint registered successfully");
+
+ // Sync generated config to project settings (appsettings.json or .env)
+ logger.LogInformation("");
+ logger.LogInformation("Syncing configuration to project settings...");
+
+ var configFileInfo = new FileInfo(configPath);
+ var generatedConfigPath = Path.Combine(
+ configFileInfo.DirectoryName ?? Environment.CurrentDirectory,
+ "a365.generated.config.json");
+
+ try
+ {
+ await ProjectSettingsSyncHelper.ExecuteAsync(
+ a365ConfigPath: configPath,
+ a365GeneratedPath: generatedConfigPath,
+ configService: configService,
+ platformDetector: platformDetector,
+ logger: logger);
+
+ logger.LogInformation("Configuration synced to project settings successfully");
+ }
+ catch (Exception syncEx)
+ {
+ logger.LogWarning(syncEx, "Project settings sync failed (non-blocking). Please sync settings manually if needed.");
+ }
+ }
+
+ #endregion
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs
new file mode 100644
index 00000000..2e5c4fde
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs
@@ -0,0 +1,631 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Agents.A365.DevTools.Cli.Constants;
+using Microsoft.Agents.A365.DevTools.Cli.Exceptions;
+using Microsoft.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Extensions.Logging;
+using System.CommandLine;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands;
+
+///
+/// Infrastructure subcommand - Creates Azure infrastructure (Resource Group, App Service Plan, Web App, MSI)
+/// Required Permissions: Azure Subscription Contributor/Owner
+/// COMPLETE REPLICATION of A365SetupRunner Phase 0 and Phase 1 functionality
+///
+public static class InfrastructureSubcommand
+{
+ public static Command CreateCommand(
+ ILogger logger,
+ IConfigService configService,
+ IAzureValidator azureValidator,
+ AzureWebAppCreator webAppCreator,
+ PlatformDetector platformDetector,
+ CommandExecutor executor)
+ {
+ var command = new Command("infrastructure",
+ "Create Azure infrastructure\n" +
+ "Minimum required permissions: Azure Subscription Contributor or Owner\n");
+
+ var configOption = new Option(
+ ["--config", "-c"],
+ getDefaultValue: () => new FileInfo("a365.config.json"),
+ description: "Configuration file path");
+
+ var verboseOption = new Option(
+ ["--verbose", "-v"],
+ description: "Show detailed output");
+
+ var dryRunOption = new Option(
+ "--dry-run",
+ description: "Show what would be done without executing");
+
+ command.AddOption(configOption);
+ command.AddOption(verboseOption);
+ command.AddOption(dryRunOption);
+
+ command.SetHandler(async (config, verbose, dryRun) =>
+ {
+ if (dryRun)
+ {
+ var dryRunConfig = await configService.LoadAsync(config.FullName);
+
+ logger.LogInformation("DRY RUN: Create Azure Infrastructure");
+ logger.LogInformation("Would create the following resources:");
+ logger.LogInformation(" - Resource Group: {ResourceGroup}", dryRunConfig.ResourceGroup);
+ logger.LogInformation(" - Location: {Location}", dryRunConfig.Location);
+ logger.LogInformation(" - App Service Plan: {PlanName} (SKU: {Sku})",
+ dryRunConfig.AppServicePlanName, dryRunConfig.AppServicePlanSku);
+ logger.LogInformation(" - Web App: {WebAppName}", dryRunConfig.WebAppName);
+ logger.LogInformation(" - Managed Service Identity: Enabled");
+
+ // Detect platform (even in dry-run for informational purposes)
+ if (!string.IsNullOrWhiteSpace(dryRunConfig.DeploymentProjectPath))
+ {
+ var detectedPlatform = platformDetector.Detect(dryRunConfig.DeploymentProjectPath);
+ var detectedRuntime = GetRuntimeForPlatform(detectedPlatform);
+ logger.LogInformation(" - Detected Platform: {Platform}", detectedPlatform);
+ logger.LogInformation(" - Runtime: {Runtime}", detectedRuntime);
+ }
+
+ return;
+ }
+
+ // Load configuration - ConfigService automatically finds generated config in same directory
+ var setupConfig = await configService.LoadAsync(config.FullName);
+ if (setupConfig.NeedDeployment)
+ {
+ // Validate Azure CLI authentication, subscription, and environment
+ if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId))
+ {
+ Environment.Exit(1);
+ }
+ }
+ else
+ {
+ logger.LogInformation("NeedDeployment=false – skipping Azure subscription validation.");
+ }
+
+ var generatedConfigPath = Path.Combine(
+ config.DirectoryName ?? Environment.CurrentDirectory,
+ "a365.generated.config.json");
+
+ await CreateInfrastructureImplementationAsync(
+ logger,
+ config.FullName,
+ generatedConfigPath,
+ executor,
+ platformDetector,
+ setupConfig.NeedDeployment,
+ false,
+ CancellationToken.None);
+
+ logger.LogInformation("");
+ logger.LogInformation("Next steps: Run 'a365 setup blueprint' to create the agent blueprint");
+
+ }, configOption, verboseOption, dryRunOption);
+
+ return command;
+ }
+
+ #region Public Static Methods (Reusable by A365SetupRunner)
+
+ public static async Task CreateInfrastructureImplementationAsync(
+ ILogger logger,
+ string configPath,
+ string generatedConfigPath,
+ CommandExecutor commandExecutor,
+ PlatformDetector platformDetector,
+ bool needDeployment,
+ bool skipInfrastructure,
+ CancellationToken cancellationToken)
+ {
+ if (!File.Exists(configPath))
+ {
+ logger.LogError("Config file not found at {Path}", configPath);
+ return false;
+ }
+
+ JsonObject cfg;
+ try
+ {
+ cfg = JsonNode.Parse(await File.ReadAllTextAsync(configPath, cancellationToken))!.AsObject();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to parse config JSON: {Path}", configPath);
+ return false;
+ }
+
+ string Get(string name) => cfg.TryGetPropertyValue(name, out var node) && node is JsonValue jv && jv.TryGetValue(out string? s) ? s ?? string.Empty : string.Empty;
+
+ var subscriptionId = Get("subscriptionId");
+ var tenantId = Get("tenantId");
+ var resourceGroup = Get("resourceGroup");
+ var planName = Get("appServicePlanName");
+ var webAppName = Get("webAppName");
+ var location = Get("location");
+ var planSku = Get("appServicePlanSku");
+ if (string.IsNullOrWhiteSpace(planSku)) planSku = ConfigConstants.DefaultAppServicePlanSku;
+
+ var deploymentProjectPath = Get("deploymentProjectPath");
+
+ var skipInfra = skipInfrastructure || !needDeployment;
+ var externalHosting = !needDeployment && !skipInfrastructure;
+
+ if (!skipInfra)
+ {
+ // Azure hosting scenario – need full infra details
+ if (new[] { subscriptionId, resourceGroup, planName, webAppName, location }.Any(string.IsNullOrWhiteSpace))
+ {
+ logger.LogError(
+ "Config missing required properties for Azure hosting. " +
+ "Need subscriptionId, resourceGroup, appServicePlanName, webAppName, location.");
+ return false;
+ }
+ }
+ else
+ {
+ // Non-Azure hosting or --blueprint: no infra required
+ if (string.IsNullOrWhiteSpace(subscriptionId))
+ {
+ logger.LogWarning(
+ "subscriptionId is not set. This is acceptable for blueprint-only or External hosting mode " +
+ "as Azure infrastructure will not be provisioned.");
+ }
+ }
+
+ // Detect project platform for appropriate runtime configuration
+ var platform = Models.ProjectPlatform.DotNet; // Default fallback
+ if (!string.IsNullOrWhiteSpace(deploymentProjectPath))
+ {
+ platform = platformDetector.Detect(deploymentProjectPath);
+ logger.LogInformation("Detected project platform: {Platform}", platform);
+ }
+ else
+ {
+ logger.LogWarning("No deploymentProjectPath specified, defaulting to .NET runtime");
+ }
+
+ logger.LogInformation("Agent 365 Setup Infrastructure - Starting...");
+ logger.LogInformation("Subscription: {Sub}", subscriptionId);
+ logger.LogInformation("Resource Group: {RG}", resourceGroup);
+ logger.LogInformation("App Service Plan: {Plan}", planName);
+ logger.LogInformation("Web App: {App}", webAppName);
+ logger.LogInformation("Location: {Loc}", location);
+ logger.LogInformation("");
+
+ if (!skipInfra)
+ {
+ bool isValidated = await ValidateAzureCliAuthenticationAsync(
+ commandExecutor,
+ tenantId,
+ logger,
+ cancellationToken);
+
+ if (!isValidated)
+ {
+ return false;
+ }
+ }
+ else
+ {
+ logger.LogInformation("==> Skipping Azure management authentication (--skipInfrastructure or External hosting)");
+ }
+
+ await CreateInfrastructureAsync(
+ commandExecutor,
+ subscriptionId,
+ tenantId,
+ resourceGroup,
+ location,
+ planName,
+ planSku,
+ webAppName,
+ generatedConfigPath,
+ platform,
+ logger,
+ needDeployment,
+ skipInfra,
+ externalHosting,
+ cancellationToken);
+
+ return true;
+ }
+
+ ///
+ /// Phase 0: Validate Azure CLI authentication and acquire management scope token
+ ///
+ public static async Task ValidateAzureCliAuthenticationAsync(
+ CommandExecutor executor,
+ string tenantId,
+ ILogger logger,
+ CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("==> Verifying Azure CLI authentication");
+
+ // Check if logged in
+ var accountCheck = await executor.ExecuteAsync("az", "account show", captureOutput: true, suppressErrorLogging: true, cancellationToken: cancellationToken);
+ if (!accountCheck.Success)
+ {
+ logger.LogInformation("Azure CLI not authenticated. Initiating login with management scope...");
+ logger.LogInformation("A browser window will open for authentication.");
+
+ var loginResult = await executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken);
+
+ if (!loginResult.Success)
+ {
+ logger.LogError("Azure CLI login failed. Please run manually: az login --scope https://management.core.windows.net//.default");
+ return false;
+ }
+
+ logger.LogInformation("Azure CLI login successful!");
+ await Task.Delay(2000, cancellationToken);
+ }
+ else
+ {
+ logger.LogInformation("Azure CLI already authenticated");
+ }
+
+ // Verify we have the management scope
+ logger.LogInformation("Verifying access to Azure management resources...");
+ var tokenCheck = await executor.ExecuteAsync(
+ "az",
+ "account get-access-token --resource https://management.core.windows.net/ --query accessToken -o tsv",
+ captureOutput: true,
+ suppressErrorLogging: true,
+ cancellationToken: cancellationToken);
+
+ if (!tokenCheck.Success)
+ {
+ logger.LogWarning("Unable to acquire management scope token. Attempting re-authentication...");
+ logger.LogInformation("A browser window will open for authentication.");
+
+ var loginResult = await executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken);
+
+ if (!loginResult.Success)
+ {
+ logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default");
+ return false;
+ }
+
+ logger.LogInformation("Azure CLI re-authentication successful!");
+ await Task.Delay(2000, cancellationToken);
+
+ var retryTokenCheck = await executor.ExecuteAsync(
+ "az",
+ "account get-access-token --resource https://management.core.windows.net/ --query accessToken -o tsv",
+ captureOutput: true,
+ suppressErrorLogging: true,
+ cancellationToken: cancellationToken);
+
+ if (!retryTokenCheck.Success)
+ {
+ logger.LogWarning("Still unable to acquire management scope token after re-authentication.");
+ logger.LogWarning("Continuing anyway - you may encounter permission errors later.");
+ }
+ else
+ {
+ logger.LogInformation("Management scope token acquired successfully!");
+ }
+ }
+ else
+ {
+ logger.LogInformation("Management scope verified successfully");
+ }
+
+ logger.LogInformation("");
+ return true;
+ }
+
+ ///
+ /// Phase 1: Create Azure infrastructure (Resource Group, App Service Plan, Web App, Managed Identity)
+ /// Equivalent to A365SetupRunner Phase 1 (lines 223-334)
+ /// Returns the Managed Identity Principal ID (or null if not assigned)
+ ///
+ public static async Task CreateInfrastructureAsync(
+ CommandExecutor executor,
+ string subscriptionId,
+ string tenantId,
+ string resourceGroup,
+ string location,
+ string planName,
+ string? planSku,
+ string webAppName,
+ string generatedConfigPath,
+ Models.ProjectPlatform platform,
+ ILogger logger,
+ bool needDeployment,
+ bool skipInfra,
+ bool externalHosting,
+ CancellationToken cancellationToken = default)
+ {
+ string? principalId = null;
+ JsonObject generatedConfig = new JsonObject();
+
+ if (skipInfra)
+ {
+ var modeMessage = "External hosting (non-Azure)";
+
+ logger.LogInformation("==> Skipping Azure infrastructure ({Mode})", modeMessage);
+ logger.LogInformation("Loading existing configuration...");
+
+ // Load existing generated config if available
+ if (File.Exists(generatedConfigPath))
+ {
+ try
+ {
+ generatedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath, cancellationToken))?.AsObject() ?? new JsonObject();
+
+ if (generatedConfig.TryGetPropertyValue("managedIdentityPrincipalId", out var existingPrincipalId))
+ {
+ // Only reuse MSI in blueprint-only mode
+ principalId = existingPrincipalId?.GetValue();
+ logger.LogInformation("Found existing Managed Identity Principal ID: {Id}", principalId ?? "(none)");
+ }
+ else if (externalHosting)
+ {
+ logger.LogInformation("External hosting selected - Managed Identity will NOT be used.");
+
+ // Make sure we don't create FIC later
+ principalId = null;
+ }
+
+ logger.LogInformation("Existing configuration loaded successfully");
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning("Could not load existing config: {Message}. Starting fresh.", ex.Message);
+ }
+ }
+ else
+ {
+ logger.LogInformation("No existing configuration found - blueprint will be created without managed identity");
+ }
+
+ logger.LogInformation("");
+ }
+ else
+ {
+ logger.LogInformation("==> Deploying App Service + enabling Managed Identity");
+
+ // Set subscription context
+ try
+ {
+ await executor.ExecuteAsync("az", $"account set --subscription {subscriptionId}");
+ }
+ catch (Exception)
+ {
+ logger.LogWarning("Failed to set az subscription context explicitly");
+ }
+
+ // Resource group
+ var rgExists = await executor.ExecuteAsync("az", $"group exists -n {resourceGroup} --subscription {subscriptionId}", captureOutput: true);
+ if (rgExists.Success && rgExists.StandardOutput.Trim().Equals("true", StringComparison.OrdinalIgnoreCase))
+ {
+ logger.LogInformation("Resource group already exists: {RG} (skipping creation)", resourceGroup);
+ }
+ else
+ {
+ logger.LogInformation("Creating resource group {RG}", resourceGroup);
+ await AzWarnAsync(executor, logger, $"group create -n {resourceGroup} -l {location} --subscription {subscriptionId}", "Create resource group");
+ }
+
+ // App Service plan
+ await EnsureAppServicePlanExistsAsync(executor, logger, resourceGroup, planName, planSku, subscriptionId);
+
+ // Web App
+ var webShow = await executor.ExecuteAsync("az", $"webapp show -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true);
+ if (!webShow.Success)
+ {
+ var runtime = GetRuntimeForPlatform(platform);
+ logger.LogInformation("Creating web app {App} with runtime {Runtime}", webAppName, runtime);
+ var createResult = await executor.ExecuteAsync("az", $"webapp create -g {resourceGroup} -p {planName} -n {webAppName} --runtime \"{runtime}\" --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true);
+ if (!createResult.Success)
+ {
+ if (createResult.StandardError.Contains("AuthorizationFailed", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new AzureResourceException("WebApp", webAppName, createResult.StandardError, true);
+ }
+ else
+ {
+ logger.LogError("ERROR: Web app creation failed: {Err}", createResult.StandardError);
+ throw new InvalidOperationException($"Failed to create web app '{webAppName}'. Setup cannot continue.");
+ }
+ }
+ }
+ else
+ {
+ var linuxFxVersion = GetLinuxFxVersionForPlatform(platform);
+ logger.LogInformation("Web app already exists: {App} (skipping creation)", webAppName);
+ logger.LogInformation("Configuring web app to use {Platform} runtime ({LinuxFxVersion})...", platform, linuxFxVersion);
+ await AzWarnAsync(executor, logger, $"webapp config set -g {resourceGroup} -n {webAppName} --linux-fx-version \"{linuxFxVersion}\" --subscription {subscriptionId}", "Configure runtime");
+ }
+
+ // Verify web app
+ var verifyResult = await executor.ExecuteAsync("az", $"webapp show -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true);
+ if (!verifyResult.Success)
+ {
+ logger.LogWarning("WARNING: Unable to verify web app via az webapp show.");
+ }
+ else
+ {
+ logger.LogInformation("Verified web app presence.");
+ }
+
+ // Managed Identity
+ logger.LogInformation("Assigning (or confirming) system-assigned managed identity");
+ var identity = await executor.ExecuteAsync("az", $"webapp identity assign -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}");
+ if (identity.Success)
+ {
+ try
+ {
+ var json = JsonDocument.Parse(identity.StandardOutput);
+ principalId = json.RootElement.GetProperty("principalId").GetString();
+ if (!string.IsNullOrEmpty(principalId))
+ {
+ logger.LogInformation("Managed Identity principalId: {Id}", principalId);
+ }
+ }
+ catch
+ {
+ // ignore parse error
+ }
+ }
+ else if (identity.StandardError.Contains("already has a managed identity", StringComparison.OrdinalIgnoreCase) ||
+ identity.StandardError.Contains("Conflict", StringComparison.OrdinalIgnoreCase))
+ {
+ logger.LogInformation("Managed identity already assigned (ignoring conflict).");
+ }
+ else
+ {
+ logger.LogWarning("WARNING: identity assign returned error: {Err}", identity.StandardError.Trim());
+ }
+
+ // Load or create generated config
+ if (File.Exists(generatedConfigPath))
+ {
+ try
+ {
+ generatedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath, cancellationToken))?.AsObject() ?? new JsonObject();
+ }
+ catch
+ {
+ logger.LogWarning("Could not parse existing generated config, starting fresh");
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(principalId))
+ {
+ generatedConfig["managedIdentityPrincipalId"] = principalId;
+ await File.WriteAllTextAsync(generatedConfigPath, generatedConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), cancellationToken);
+ logger.LogInformation("Generated config updated with MSI principalId: {Id}", principalId);
+ }
+
+ logger.LogInformation("Waiting 10 seconds to ensure Service Principal is fully propagated...");
+ await Task.Delay(10000, cancellationToken);
+ }
+ }
+
+ ///
+ /// Save Managed Identity Principal ID to a365.generated.config.json
+ /// Equivalent to A365SetupRunner logic (lines 321-332)
+ ///
+ public static async Task SaveManagedIdentityToConfigAsync(
+ string principalId,
+ string generatedConfigPath,
+ ILogger logger,
+ CancellationToken cancellationToken = default)
+ {
+ // Load or create generated config
+ JsonObject generatedConfig = new JsonObject();
+ if (File.Exists(generatedConfigPath))
+ {
+ try
+ {
+ generatedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath, cancellationToken))?.AsObject() ?? new JsonObject();
+ }
+ catch
+ {
+ logger.LogWarning("Could not parse existing generated config, starting fresh");
+ }
+ }
+
+ generatedConfig["managedIdentityPrincipalId"] = principalId;
+ await File.WriteAllTextAsync(generatedConfigPath,
+ generatedConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }),
+ cancellationToken);
+
+ logger.LogInformation("Generated config updated with MSI principalId: {Id}", principalId);
+ }
+
+ #endregion
+
+ #region Private Helper Methods
+
+ private static async Task AzWarnAsync(CommandExecutor executor, ILogger logger, string args, string description)
+ {
+ var result = await executor.ExecuteAsync("az", args, suppressErrorLogging: true);
+ if (!result.Success)
+ {
+ if (result.StandardError.Contains("already exists", StringComparison.OrdinalIgnoreCase))
+ {
+ logger.LogInformation("{Description} already exists (skipping creation)", description);
+ }
+ else if (result.StandardError.Contains("AuthorizationFailed", StringComparison.OrdinalIgnoreCase))
+ {
+ var exception = new AzureResourceException(description, string.Empty, result.StandardError, true);
+ ExceptionHandler.HandleAgent365Exception(exception);
+ }
+ else
+ {
+ logger.LogWarning("az {Description} returned non-success (exit code {Code}). Error: {Err}",
+ description, result.ExitCode, Short(result.StandardError));
+ }
+ }
+ }
+
+ ///
+ /// Ensures that an App Service Plan exists, creating it if necessary and verifying its existence.
+ ///
+ internal static async Task EnsureAppServicePlanExistsAsync(CommandExecutor executor, ILogger logger, string resourceGroup, string planName, string? planSku, string subscriptionId)
+ {
+ var planShow = await executor.ExecuteAsync("az", $"appservice plan show -g {resourceGroup} -n {planName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true);
+ if (planShow.Success)
+ {
+ logger.LogInformation("App Service plan already exists: {Plan} (skipping creation)", planName);
+ }
+ else
+ {
+ logger.LogInformation("Creating App Service plan {Plan}", planName);
+ await AzWarnAsync(executor, logger, $"appservice plan create -g {resourceGroup} -n {planName} --sku {planSku} --is-linux --subscription {subscriptionId}", "Create App Service plan");
+
+ // Verify the plan was created successfully
+ var verifyPlan = await executor.ExecuteAsync("az", $"appservice plan show -g {resourceGroup} -n {planName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true);
+ if (!verifyPlan.Success)
+ {
+ logger.LogError("ERROR: App Service plan creation failed. The plan '{Plan}' does not exist.", planName);
+ throw new InvalidOperationException($"Failed to create App Service plan '{planName}'. This may be due to quota limits or insufficient permissions. Setup cannot continue.");
+ }
+ logger.LogInformation("App Service plan created successfully: {Plan}", planName);
+ }
+ }
+
+ ///
+ /// Get the Azure Web App runtime string based on the detected platform
+ /// (from A365SetupRunner GetRuntimeForPlatform method)
+ ///
+ private static string GetRuntimeForPlatform(Models.ProjectPlatform platform)
+ {
+ return platform switch
+ {
+ Models.ProjectPlatform.Python => "PYTHON:3.11",
+ Models.ProjectPlatform.NodeJs => "NODE:18-lts",
+ Models.ProjectPlatform.DotNet => "DOTNETCORE:8.0",
+ _ => "DOTNETCORE:8.0" // Default fallback
+ };
+ }
+
+ ///
+ /// Get the Azure Web App Linux FX Version string based on the detected platform
+ /// (from A365SetupRunner GetLinuxFxVersionForPlatform method)
+ ///
+ private static string GetLinuxFxVersionForPlatform(Models.ProjectPlatform platform)
+ {
+ return platform switch
+ {
+ Models.ProjectPlatform.Python => "PYTHON|3.11",
+ Models.ProjectPlatform.NodeJs => "NODE|20-lts",
+ Models.ProjectPlatform.DotNet => "DOTNETCORE|8.0",
+ _ => "DOTNETCORE|8.0" // Default fallback
+ };
+ }
+
+ private static string Short(string? text)
+ => string.IsNullOrWhiteSpace(text) ? string.Empty : (text.Length <= 180 ? text.Trim() : text[..177] + "...");
+
+ #endregion
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs
new file mode 100644
index 00000000..deb035ad
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs
@@ -0,0 +1,308 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Agents.A365.DevTools.Cli.Constants;
+using Microsoft.Agents.A365.DevTools.Cli.Exceptions;
+using Microsoft.Agents.A365.DevTools.Cli.Helpers;
+using Microsoft.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Extensions.Logging;
+using System.CommandLine;
+using System.Threading;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands;
+
+///
+/// Permissions subcommand - Configures OAuth2 permission grants and inheritable permissions
+/// Required Permissions: Global Administrator (for admin consent)
+///
+internal static class PermissionsSubcommand
+{
+ public static Command CreateCommand(
+ ILogger logger,
+ IConfigService configService,
+ CommandExecutor executor,
+ GraphApiService graphApiService)
+ {
+ var permissionsCommand = new Command("permissions",
+ "Configure OAuth2 permission grants and inheritable permissions\n" +
+ "Minimum required permissions: Global Administrator\n");
+
+ // Add subcommands
+ permissionsCommand.AddCommand(CreateMcpSubcommand(logger, configService, executor, graphApiService));
+ permissionsCommand.AddCommand(CreateBotSubcommand(logger, configService, executor, graphApiService));
+
+ return permissionsCommand;
+ }
+
+ ///
+ /// MCP permissions subcommand
+ ///
+ private static Command CreateMcpSubcommand(
+ ILogger logger,
+ IConfigService configService,
+ CommandExecutor executor,
+ GraphApiService graphApiService)
+ {
+ var command = new Command("mcp",
+ "Configure MCP server OAuth2 grants and inheritable permissions\n" +
+ "Minimum required permissions: Global Administrator\n\n");
+
+ var configOption = new Option(
+ ["--config", "-c"],
+ getDefaultValue: () => new FileInfo("a365.config.json"),
+ description: "Configuration file path");
+
+ var verboseOption = new Option(
+ ["--verbose", "-v"],
+ description: "Show detailed output");
+
+ var dryRunOption = new Option(
+ "--dry-run",
+ description: "Show what would be done without executing");
+
+ command.AddOption(configOption);
+ command.AddOption(verboseOption);
+ command.AddOption(dryRunOption);
+
+ command.SetHandler(async (config, verbose, dryRun) =>
+ {
+ var setupConfig = await configService.LoadAsync(config.FullName);
+
+ if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId))
+ {
+ logger.LogError("Blueprint ID not found. Run 'a365 setup blueprint' first.");
+ Environment.Exit(1);
+ }
+
+ if (dryRun)
+ {
+ // Read scopes from toolingManifest.json
+ var manifestPath = Path.Combine(setupConfig.DeploymentProjectPath ?? string.Empty, "toolingManifest.json");
+ var toolingScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath);
+
+ logger.LogInformation("DRY RUN: Configure MCP Permissions");
+ logger.LogInformation("Would configure OAuth2 grants and inheritable permissions:");
+ logger.LogInformation(" - Blueprint: {BlueprintId}", setupConfig.AgentBlueprintId);
+ logger.LogInformation(" - Resource: Agent 365 Tools ({Environment})", setupConfig.Environment);
+ logger.LogInformation(" - Scopes: {Scopes}", string.Join(", ", toolingScopes));
+ return;
+ }
+
+ await ConfigureMcpPermissionsAsync(
+ config.FullName,
+ logger,
+ configService,
+ executor,
+ graphApiService,
+ setupConfig,
+ false);
+
+ }, configOption, verboseOption, dryRunOption);
+
+ return command;
+ }
+
+ ///
+ /// Bot API permissions subcommand
+ ///
+ private static Command CreateBotSubcommand(
+ ILogger logger,
+ IConfigService configService,
+ CommandExecutor executor,
+ GraphApiService graphApiService)
+ {
+ var command = new Command("bot",
+ "Configure Messaging Bot API OAuth2 grants and inheritable permissions\n" +
+ "Minimum required permissions: Global Administrator\n\n" +
+ "Prerequisites: Blueprint and MCP permissions (run 'a365 setup permissions mcp' first)\n" +
+ "Next step: a365 setup endpoint");
+
+ var configOption = new Option(
+ ["--config", "-c"],
+ getDefaultValue: () => new FileInfo("a365.config.json"),
+ description: "Configuration file path");
+
+ var verboseOption = new Option(
+ ["--verbose", "-v"],
+ description: "Show detailed output");
+
+ var dryRunOption = new Option(
+ "--dry-run",
+ description: "Show what would be done without executing");
+
+ command.AddOption(configOption);
+ command.AddOption(verboseOption);
+ command.AddOption(dryRunOption);
+
+ command.SetHandler(async (config, verbose, dryRun) =>
+ {
+ var setupConfig = await configService.LoadAsync(config.FullName);
+
+ if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId))
+ {
+ logger.LogError("Blueprint ID not found. Run 'a365 setup blueprint' first.");
+ Environment.Exit(1);
+ }
+
+ if (dryRun)
+ {
+ logger.LogInformation("DRY RUN: Configure Bot API Permissions");
+ logger.LogInformation("Would configure Messaging Bot API permissions:");
+ logger.LogInformation(" - Blueprint: {BlueprintId}", setupConfig.AgentBlueprintId);
+ logger.LogInformation(" - Scopes: Authorization.ReadWrite, user_impersonation");
+ return;
+ }
+
+ await ConfigureBotPermissionsAsync(
+ config.FullName,
+ logger,
+ configService,
+ executor,
+ setupConfig,
+ graphApiService,
+ false);
+
+ }, configOption, verboseOption, dryRunOption);
+
+ return command;
+ }
+
+ ///
+ /// Configures MCP server permissions (OAuth2 grants and inheritable permissions).
+ /// Public method that can be called by AllSubcommand.
+ ///
+ public static async Task ConfigureMcpPermissionsAsync(
+ string configPath,
+ ILogger logger,
+ IConfigService configService,
+ CommandExecutor executor,
+ GraphApiService graphApiService,
+ Models.Agent365Config setupConfig,
+ bool iSetupAll,
+ CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("");
+ logger.LogInformation("Configuring MCP server permissions...");
+ logger.LogInformation("");
+
+ try
+ {
+ // Read scopes from toolingManifest.json
+ var manifestPath = Path.Combine(setupConfig.DeploymentProjectPath ?? string.Empty, "toolingManifest.json");
+ var toolingScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath);
+
+ // OAuth2 permission grants
+ await SetupHelpers.EnsureMcpOauth2PermissionGrantsAsync(
+ graphApiService, setupConfig, toolingScopes, logger);
+
+ // Inheritable permissions
+ await SetupHelpers.EnsureMcpInheritablePermissionsAsync(
+ graphApiService, setupConfig, toolingScopes, logger);
+
+ logger.LogInformation("");
+ logger.LogInformation("MCP server permissions configured successfully");
+ logger.LogInformation("");
+ if (!iSetupAll)
+ {
+ logger.LogInformation("Next step: 'a365 setup permissions bot' to configure Bot API permissions");
+ }
+
+ // write changes to generated config
+ await configService.SaveStateAsync(setupConfig);
+ return true;
+ }
+ catch (Exception mcpEx)
+ {
+ logger.LogError("Failed to configure MCP server permissions: {Message}", mcpEx.Message);
+ logger.LogInformation("To configure MCP permissions manually:");
+ logger.LogInformation(" 1. Ensure the agent blueprint has the required permissions in Azure Portal");
+ logger.LogInformation(" 2. Grant admin consent for the MCP scopes");
+ logger.LogInformation(" 3. Run 'a365 setup mcp' to retry MCP permission configuration");
+ if (iSetupAll)
+ {
+ throw;
+ }
+ return false;
+ }
+ }
+
+ ///
+ /// Configures Bot API permissions (OAuth2 grants and inheritable permissions).
+ /// Public method that can be called by AllSubcommand.
+ ///
+ public static async Task ConfigureBotPermissionsAsync(
+ string configPath,
+ ILogger logger,
+ IConfigService configService,
+ CommandExecutor executor,
+ Models.Agent365Config setupConfig,
+ GraphApiService graphService,
+ bool iSetupAll,
+ CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("");
+ logger.LogInformation("Configuring Messaging Bot API permissions...");
+ logger.LogInformation("");
+
+ try
+ {
+ if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId))
+ {
+ throw new SetupValidationException("AgentBlueprintId is required.");
+ }
+
+ var blueprintSpObjectId = await graphService.LookupServicePrincipalByAppIdAsync(setupConfig.TenantId, setupConfig.AgentBlueprintId)
+ ?? throw new SetupValidationException($"Blueprint Service Principal not found for appId {setupConfig.AgentBlueprintId}");
+
+ // Ensure Messaging Bot API SP exists
+ var botApiResourceSpObjectId = await graphService.EnsureServicePrincipalForAppIdAsync(
+ setupConfig.TenantId,
+ ConfigConstants.MessagingBotApiAppId);
+
+ // Grant OAuth2 permissions
+ var botApiGrantOk = await graphService.CreateOrUpdateOauth2PermissionGrantAsync(
+ setupConfig.TenantId,
+ blueprintSpObjectId,
+ botApiResourceSpObjectId,
+ new[] { "Authorization.ReadWrite", "user_impersonation" });
+
+ if (!botApiGrantOk)
+ {
+ throw new InvalidOperationException("Failed to create/update oauth2PermissionGrant for Messaging Bot API");
+ }
+
+ // Set inheritable permissions
+ var (ok, already, err) = await graphService.SetInheritablePermissionsAsync(
+ setupConfig.TenantId,
+ setupConfig.AgentBlueprintId,
+ ConfigConstants.MessagingBotApiAppId,
+ new[] { "Authorization.ReadWrite", "user_impersonation" });
+
+ if (!ok && !already)
+ {
+ throw new InvalidOperationException($"Failed to set inheritable permissions for Messaging Bot API: {err}");
+ }
+
+ // write changes to generated config
+ await configService.SaveStateAsync(setupConfig);
+
+ logger.LogInformation("");
+ logger.LogInformation("Messaging Bot API permissions configured successfully");
+ logger.LogInformation("");
+ if (!iSetupAll)
+ {
+ logger.LogInformation("Next step: Run 'a365 setup endpoint' to register messaging endpoint");
+ }
+ return true;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to configure Bot API permissions: {Message}", ex.Message);
+ if (iSetupAll)
+ {
+ throw;
+ }
+ return false;
+ }
+ }
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs
new file mode 100644
index 00000000..877669af
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs
@@ -0,0 +1,380 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Agents.A365.DevTools.Cli.Constants;
+using Microsoft.Agents.A365.DevTools.Cli.Exceptions;
+using Microsoft.Agents.A365.DevTools.Cli.Models;
+using Microsoft.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands;
+
+///
+/// Shared helper methods for setup subcommands
+///
+internal static class SetupHelpers
+{
+ ///
+ /// Display verification URLs and next steps after successful setup
+ ///
+ public static async Task DisplayVerificationInfoAsync(FileInfo setupConfigFile, ILogger logger)
+ {
+ try
+ {
+ logger.LogInformation("Generating verification information...");
+ var baseDir = setupConfigFile.DirectoryName ?? Environment.CurrentDirectory;
+ var generatedConfigPath = Path.Combine(baseDir, "a365.generated.config.json");
+
+ if (!File.Exists(generatedConfigPath))
+ {
+ logger.LogWarning("Generated config not found - skipping verification info");
+ return;
+ }
+
+ using var stream = File.OpenRead(generatedConfigPath);
+ using var doc = await JsonDocument.ParseAsync(stream);
+ var root = doc.RootElement;
+
+ logger.LogInformation("");
+ logger.LogInformation("Verification URLs and Next Steps:");
+ logger.LogInformation("==========================================");
+
+ // Azure Web App URL
+ if (root.TryGetProperty("AppServiceName", out var appServiceProp) && !string.IsNullOrWhiteSpace(appServiceProp.GetString()))
+ {
+ var webAppUrl = $"https://{appServiceProp.GetString()}.azurewebsites.net";
+ logger.LogInformation("Agent Web App: {Url}", webAppUrl);
+ }
+
+ // Azure Resource Group
+ if (root.TryGetProperty("ResourceGroup", out var rgProp) && !string.IsNullOrWhiteSpace(rgProp.GetString()))
+ {
+ var resourceGroup = rgProp.GetString();
+ logger.LogInformation("Azure Resource Group: https://portal.azure.com/#@/resource/subscriptions/{SubscriptionId}/resourceGroups/{ResourceGroup}",
+ root.TryGetProperty("SubscriptionId", out var subProp) ? subProp.GetString() : "{subscription}",
+ resourceGroup);
+ }
+
+ // Entra ID Application
+ if (root.TryGetProperty("AgentBlueprintId", out var blueprintProp) && !string.IsNullOrWhiteSpace(blueprintProp.GetString()))
+ {
+ logger.LogInformation("Entra ID Application: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/{AppId}",
+ blueprintProp.GetString());
+ }
+
+ logger.LogInformation("");
+ logger.LogInformation("Next Steps:");
+ logger.LogInformation(" 1. Review Azure resources in the portal");
+ logger.LogInformation(" 2. View configuration: a365 config display");
+ logger.LogInformation(" 3. Create agent instance: a365 create-instance identity");
+ logger.LogInformation(" 4. Deploy application: a365 deploy app");
+ logger.LogInformation("");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Could not display verification info: {Message}", ex.Message);
+ }
+ }
+
+ ///
+ /// Display comprehensive setup summary showing what succeeded and what failed
+ ///
+ public static void DisplaySetupSummary(SetupResults results, ILogger logger)
+ {
+ logger.LogInformation("");
+ logger.LogInformation("==========================================");
+ logger.LogInformation("Setup Summary");
+ logger.LogInformation("==========================================");
+
+ // Show what succeeded
+ logger.LogInformation("Completed Steps:");
+ if (results.InfrastructureCreated)
+ {
+ logger.LogInformation(" [OK] Infrastructure created");
+ }
+ if (results.BlueprintCreated)
+ {
+ logger.LogInformation(" [OK] Agent blueprint created (Blueprint ID: {BlueprintId})", results.BlueprintId ?? "unknown");
+ }
+ if (results.McpPermissionsConfigured)
+ logger.LogInformation(" [OK] MCP server permissions configured");
+ if (results.InheritablePermissionsConfigured)
+ logger.LogInformation(" [OK] Inheritable permissions configured");
+ if (results.BotApiPermissionsConfigured)
+ logger.LogInformation(" [OK] Messaging Bot API permissions configured");
+ if (results.MessagingEndpointRegistered)
+ logger.LogInformation(" [OK] Messaging endpoint registered");
+
+ // Show what failed
+ if (results.Errors.Count > 0)
+ {
+ logger.LogInformation("");
+ logger.LogInformation("Failed Steps:");
+ foreach (var error in results.Errors)
+ {
+ logger.LogInformation(" [FAILED] {Error}", error);
+ }
+ }
+
+ // Show warnings
+ if (results.Warnings.Count > 0)
+ {
+ logger.LogInformation("");
+ logger.LogInformation("Warnings:");
+ foreach (var warning in results.Warnings)
+ {
+ logger.LogInformation(" [WARN] {Warning}", warning);
+ }
+ }
+
+ logger.LogInformation("");
+
+ // Overall status
+ if (results.HasErrors)
+ {
+ logger.LogWarning("Setup completed with errors");
+ logger.LogInformation("");
+ logger.LogInformation("Recovery Actions:");
+
+ if (!results.InheritablePermissionsConfigured)
+ {
+ logger.LogInformation(" - Inheritable Permissions: Run 'a365 setup permissions mcp' to retry");
+ }
+
+ if (!results.McpPermissionsConfigured)
+ {
+ logger.LogInformation(" - MCP Permissions: Run 'a365 setup permissions mcp' to retry");
+ }
+
+ if (!results.BotApiPermissionsConfigured)
+ {
+ logger.LogInformation(" - Bot API Permissions: Run 'a365 setup permissions bot' to retry");
+ }
+
+ if (!results.MessagingEndpointRegistered)
+ {
+ logger.LogInformation(" - Messaging Endpoint: Run 'a365 setup endpoint' to retry");
+ }
+ }
+ else if (results.HasWarnings)
+ {
+ logger.LogInformation("Setup completed successfully with warnings");
+ logger.LogInformation("Review warnings above and take action if needed");
+ }
+ else
+ {
+ logger.LogInformation("Setup completed successfully");
+ logger.LogInformation("All components configured correctly");
+ }
+
+ logger.LogInformation("==========================================");
+ }
+
+ ///
+ /// Ensure MCP OAuth2 permission grants (admin consent)
+ ///
+ public static async Task EnsureMcpOauth2PermissionGrantsAsync(
+ GraphApiService graph,
+ Agent365Config config,
+ string[] scopes,
+ ILogger logger,
+ CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(config.AgentBlueprintId))
+ throw new SetupValidationException("AgentBlueprintId (appId) is required.");
+
+ var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, config.AgentBlueprintId, ct);
+ if (string.IsNullOrWhiteSpace(blueprintSpObjectId))
+ {
+ throw new SetupValidationException($"Blueprint Service Principal not found for appId {config.AgentBlueprintId}. " +
+ "The service principal may not have propagated yet. Wait a few minutes and retry.");
+ }
+
+ var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment);
+ var Agent365ToolsSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct);
+ if (string.IsNullOrWhiteSpace(Agent365ToolsSpObjectId))
+ {
+ throw new SetupValidationException($"Agent 365 Tools Service Principal not found for appId {resourceAppId}. " +
+ $"Ensure the Agent 365 Tools application is available in your tenant for environment: {config.Environment}");
+ }
+
+ logger.LogInformation(" - OAuth2 grant: client {ClientId} to resource {ResourceId} scopes [{Scopes}]",
+ blueprintSpObjectId, Agent365ToolsSpObjectId, string.Join(' ', scopes));
+
+ var response = await graph.CreateOrUpdateOauth2PermissionGrantAsync(
+ config.TenantId, blueprintSpObjectId, Agent365ToolsSpObjectId, scopes, ct);
+
+ if (!response)
+ {
+ throw new SetupValidationException(
+ $"Failed to create/update OAuth2 permission grant from blueprint {config.AgentBlueprintId} to Agent 365 Tools {resourceAppId}. " +
+ "This may be due to insufficient permissions. Ensure you have DelegatedPermissionGrant.ReadWrite.All or Application.ReadWrite.All permissions.");
+ }
+ }
+
+ ///
+ /// Ensure MCP inheritable permissions on blueprint
+ ///
+ public static async Task EnsureMcpInheritablePermissionsAsync(
+ GraphApiService graph,
+ Agent365Config config,
+ string[] scopes,
+ ILogger logger,
+ CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(config.AgentBlueprintId))
+ throw new SetupValidationException("AgentBlueprintId (appId) is required.");
+
+ var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment);
+
+ logger.LogInformation(" - Inheritable permissions: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]",
+ config.AgentBlueprintId, resourceAppId, string.Join(' ', scopes));
+
+ var (ok, alreadyExists, err) = await graph.SetInheritablePermissionsAsync(
+ config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, new List() { "AgentIdentityBlueprint.ReadWrite.All" }, ct);
+
+ if (!ok && !alreadyExists)
+ {
+ config.InheritanceConfigured = false;
+ config.InheritanceConfigError = err;
+ throw new SetupValidationException($"Failed to set inheritable permissions: {err}. " +
+ "Ensure you have Application.ReadWrite.All permissions and the blueprint supports inheritable permissions.");
+ }
+
+ config.InheritanceConfigured = true;
+ config.InheritablePermissionsAlreadyExist = alreadyExists;
+ config.InheritanceConfigError = null;
+ }
+
+ ///
+ /// Register blueprint messaging endpoint
+ ///
+ public static async Task RegisterBlueprintMessagingEndpointAsync(
+ Agent365Config setupConfig,
+ ILogger logger,
+ IBotConfigurator botConfigurator)
+ {
+ // Validate required configuration
+ if (string.IsNullOrEmpty(setupConfig.AgentBlueprintId))
+ {
+ logger.LogError("Agent Blueprint ID not found. Blueprint creation may have failed.");
+ throw new SetupValidationException(
+ issueDescription: "Agent blueprint was not found – messaging endpoint cannot be registered.",
+ errorDetails: new List
+ {
+ "AgentBlueprintId is missing from configuration. This usually means the blueprint creation step failed or a365.generated.config.json is out of sync."
+ },
+ mitigationSteps: new List
+ {
+ "Verify that 'a365 setup' completed Step 1 (Agent blueprint creation) without errors.",
+ "Check a365.generated.config.json for 'agentBlueprintId'. If it's missing or incorrect, re-run 'a365 setup'."
+ },
+ context: new Dictionary
+ {
+ ["AgentBlueprintId"] = setupConfig.AgentBlueprintId ?? ""
+ });
+ }
+
+ string messagingEndpoint;
+ string endpointName;
+ if (setupConfig.NeedDeployment)
+ {
+ if (string.IsNullOrEmpty(setupConfig.WebAppName))
+ {
+ logger.LogError("Web App Name not configured in a365.config.json");
+ throw new SetupValidationException(
+ issueDescription: "Web App name is required to register a messaging endpoint when needDeployment is 'yes'.",
+ errorDetails: new List
+ {
+ "NeedDeployment is true, but 'webAppName' was not provided in a365.config.json."
+ },
+ mitigationSteps: new List
+ {
+ "Open a365.config.json and ensure 'webAppName' is set to the Azure Web App name.",
+ "If you do not want the CLI to deploy an Azure Web App, set \"needDeployment\": \"no\" and provide \"MessagingEndpoint\" instead.",
+ "Re-run 'a365 setup'."
+ },
+ context: new Dictionary
+ {
+ ["needDeployment"] = setupConfig.NeedDeployment.ToString(),
+ ["webAppName"] = setupConfig.WebAppName ?? ""
+ });
+ }
+
+ // Generate endpoint name with Azure Bot Service constraints (4-42 chars)
+ var baseEndpointName = $"{setupConfig.WebAppName}-endpoint";
+ endpointName = EndpointHelper.GetEndpointName(baseEndpointName);
+
+ // Construct messaging endpoint URL from web app name
+ messagingEndpoint = $"https://{setupConfig.WebAppName}.azurewebsites.net/api/messages";
+ }
+ else // Non-Azure hosting
+ {
+ // No deployment – use the provided MessagingEndpoint
+ if (string.IsNullOrWhiteSpace(setupConfig.MessagingEndpoint))
+ {
+ logger.LogError("MessagingEndpoint must be provided in a365.config.json for non-Azure hosting.");
+ throw new SetupValidationException(
+ issueDescription: "Messaging endpoint is required for messaging endpoint registration.",
+ errorDetails: new List
+ {
+ "needDeployment is set to 'no', but MessagingEndpoint was not provided in a365.config.json."
+ },
+ mitigationSteps: new List
+ {
+ "Open your a365.config.json file.",
+ "If you want the CLI to deploy an Azure Web App, set \"needDeployment\": \"yes\" and provide \"webAppName\".",
+ "If your agent is hosted elsewhere, keep \"needDeployment\": \"no\" and add a \"MessagingEndpoint\" with a valid HTTPS URL (e.g. \"https://your-host/api/messages\").",
+ "Re-run 'a365 setup'."
+ }
+ );
+ }
+
+ if (!Uri.TryCreate(setupConfig.MessagingEndpoint, UriKind.Absolute, out var uri) ||
+ uri.Scheme != Uri.UriSchemeHttps)
+ {
+ logger.LogError("MessagingEndpoint must be a valid HTTPS URL. Current value: {Endpoint}",
+ setupConfig.MessagingEndpoint);
+ throw new SetupValidationException("MessagingEndpoint must be a valid HTTPS URL.");
+ }
+
+ messagingEndpoint = setupConfig.MessagingEndpoint;
+
+ // Derive endpoint name from host when there's no WebAppName
+ var hostPart = uri.Host.Replace('.', '-');
+ var baseEndpointName = $"{hostPart}-endpoint";
+ endpointName = EndpointHelper.GetEndpointName(baseEndpointName);
+ }
+
+ if (endpointName.Length < 4)
+ {
+ logger.LogError("Bot endpoint name '{EndpointName}' is too short (must be at least 4 characters)", endpointName);
+ throw new SetupValidationException($"Bot endpoint name '{endpointName}' is too short (must be at least 4 characters)");
+ }
+
+ logger.LogInformation(" - Registering blueprint messaging endpoint");
+ logger.LogInformation(" * Endpoint Name: {EndpointName}", endpointName);
+ logger.LogInformation(" * Messaging Endpoint: {Endpoint}", messagingEndpoint);
+ logger.LogInformation(" * Using Agent Blueprint ID: {AgentBlueprintId}", setupConfig.AgentBlueprintId);
+
+ var endpointRegistered = await botConfigurator.CreateEndpointWithAgentBlueprintAsync(
+ endpointName: endpointName,
+ location: setupConfig.Location,
+ messagingEndpoint: messagingEndpoint,
+ agentDescription: "Agent 365 messaging endpoint for automated interactions",
+ agentBlueprintId: setupConfig.AgentBlueprintId);
+
+ if (!endpointRegistered)
+ {
+ logger.LogError("Failed to register blueprint messaging endpoint");
+ throw new SetupValidationException("Blueprint messaging endpoint registration failed");
+ }
+
+ // Update Agent365Config state properties
+ setupConfig.BotId = setupConfig.AgentBlueprintId;
+ setupConfig.BotMsaAppId = setupConfig.AgentBlueprintId;
+ setupConfig.BotMessagingEndpoint = messagingEndpoint;
+ }
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs
new file mode 100644
index 00000000..cfd9e064
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands;
+
+///
+/// Tracks the results of each setup step for summary reporting
+///
+public class SetupResults
+{
+ public bool InfrastructureCreated { get; set; }
+ public bool BlueprintCreated { get; set; }
+ public string? BlueprintId { get; set; }
+ public bool McpPermissionsConfigured { get; set; }
+ public bool BotApiPermissionsConfigured { get; set; }
+ public bool MessagingEndpointRegistered { get; set; }
+ public bool InheritablePermissionsConfigured { get; set; }
+
+ public List Errors { get; } = new();
+ public List Warnings { get; } = new();
+
+ public bool HasErrors => Errors.Count > 0;
+ public bool HasWarnings => Warnings.Count > 0;
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Microsoft.Agents.A365.DevTools.Cli.csproj b/src/Microsoft.Agents.A365.DevTools.Cli/Microsoft.Agents.A365.DevTools.Cli.csproj
index a17b2743..280abfdb 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Microsoft.Agents.A365.DevTools.Cli.csproj
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Microsoft.Agents.A365.DevTools.Cli.csproj
@@ -15,6 +15,9 @@
false
+
+
+
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs
index 575fe307..6ead58f4 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs
@@ -306,6 +306,12 @@ public string BotName
[JsonPropertyName("agentBlueprintClientSecret")]
public string? AgentBlueprintClientSecret { get; set; }
+ ///
+ /// Boolean value indicating if the client secret is stored securely (e.g., in Key Vault).
+ ///
+ [JsonPropertyName("agentBlueprintClientSecretProtected")]
+ public bool AgentBlueprintClientSecretProtected { get; set; }
+
#endregion
#region Bot State
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
index 3ce0fb4f..4334621e 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
@@ -118,7 +118,6 @@ static async Task Main(string[] args)
{
// Unexpected error - this is a BUG
Log.Fatal(exception, "Application terminated unexpectedly");
- Console.Error.WriteLine();
Console.Error.WriteLine("Unexpected error occurred. This may be a bug in the CLI.");
Console.Error.WriteLine("Please report this issue at: https://github.com/microsoft/Agent365-devTools/issues");
Console.Error.WriteLine();
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs
deleted file mode 100644
index e195fe6c..00000000
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs
+++ /dev/null
@@ -1,1823 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-using Azure.Core;
-using Azure.Identity;
-using Microsoft.Agents.A365.DevTools.Cli.Constants;
-using Microsoft.Agents.A365.DevTools.Cli.Exceptions;
-using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;
-using Microsoft.Extensions.Logging;
-using Microsoft.Graph;
-using System.Net.Http.Headers;
-using System.Runtime.InteropServices;
-using System.Security.Cryptography;
-using System.Text.Json;
-using System.Text.Json.Nodes;
-
-namespace Microsoft.Agents.A365.DevTools.Cli.Services;
-
-///
-/// C# implementation of a365-setup.ps1 with full feature parity.
-/// Handles infrastructure setup, blueprint creation, consent flows, and MCP server configuration.
-///
-public sealed class A365SetupRunner
-{
- private readonly ILogger _logger;
- private readonly CommandExecutor _executor;
- private readonly GraphApiService _graphService;
- private readonly AzureWebAppCreator _webAppCreator;
- private readonly DelegatedConsentService _delegatedConsentService;
- private readonly PlatformDetector _platformDetector;
- private const string GraphResourceAppId = "00000003-0000-0000-c000-000000000000"; // Microsoft Graph
- private const string ConnectivityResourceAppId = "0ddb742a-e7dc-4899-a31e-80e797ec7144"; // Connectivity
- private const string InheritablePermissionsResourceAppIdId = "00000003-0000-0ff1-ce00-000000000000";
- private const string MicrosoftGraphCommandLineToolsAppId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; // Microsoft Graph Command Line Tools
- private const string DocumentationMessage = "See documentation at https://aka.ms/agent365/setup for more information.";
-
- public A365SetupRunner(
- ILogger logger,
- CommandExecutor executor,
- GraphApiService graphService,
- AzureWebAppCreator webAppCreator,
- DelegatedConsentService delegatedConsentService,
- PlatformDetector platformDetector)
- {
- _logger = logger;
- _executor = executor;
- _graphService = graphService;
- _webAppCreator = webAppCreator;
- _delegatedConsentService = delegatedConsentService;
- _platformDetector = platformDetector;
- }
-
- ///
- /// Validates Graph token string for disallowed high-privilege scopes.
- /// Throws GraphTokenScopeException if Directory.AccessAsUser.All is present.
- ///
- private void ValidateGraphToken(string? token)
- {
- if (string.IsNullOrWhiteSpace(token))
- return;
-
- try
- {
- // JWT format: header.payload.signature
- var parts = token.Split('.');
- if (parts.Length < 2) return;
-
- string payload = parts[1];
- // Base64URL decode
- payload = payload.Replace('-', '+').Replace('_', '/');
- switch (payload.Length % 4)
- {
- case 2: payload += "=="; break;
- case 3: payload += "="; break;
- }
-
- var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload));
- using var doc = JsonDocument.Parse(json);
-
- // scopes may appear as 'scp' (space-separated) or 'roles' or 'wids'. Check scp
- if (doc.RootElement.TryGetProperty("scp", out var scp))
- {
- var scpValue = scp.GetString() ?? string.Empty;
- if (scpValue.Split(' ', StringSplitOptions.RemoveEmptyEntries).Any(s => s.Equals("Directory.AccessAsUser.All", StringComparison.OrdinalIgnoreCase)))
- {
- _logger.LogError("Detected high-privilege scope in token: Directory.AccessAsUser.All");
- throw new GraphTokenScopeException("Directory.AccessAsUser.All");
- }
- }
- }
- catch (Exception ex) when (ex is not Agent365Exception)
- {
- _logger.LogDebug(ex, "Failed to validate graph token payload");
- }
- }
-
- ///
- /// Execute setup using provided JSON config file.
- /// Fully compatible with a365-setup.ps1 functionality.
- ///
- /// Path to a365.config.json
- /// Path where a365.generated.config.json will be written
- /// If true, skip Azure infrastructure (Phase 1) and create blueprint only
- /// Cancellation token
- public async Task RunAsync(string configPath, string generatedConfigPath, bool blueprintOnly = false, CancellationToken cancellationToken = default)
- {
- if (!File.Exists(configPath))
- {
- _logger.LogError("Config file not found at {Path}", configPath);
- return false;
- }
-
- JsonObject cfg;
- try
- {
- cfg = JsonNode.Parse(await File.ReadAllTextAsync(configPath, cancellationToken))!.AsObject();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to parse config JSON: {Path}", configPath);
- return false;
- }
-
- string Get(string name) => cfg.TryGetPropertyValue(name, out var node) && node is JsonValue jv && jv.TryGetValue(out string? s) ? s ?? string.Empty : string.Empty;
-
- var subscriptionId = Get("subscriptionId");
- var tenantId = Get("tenantId");
- var resourceGroup = Get("resourceGroup");
- var planName = Get("appServicePlanName");
- var webAppName = Get("webAppName");
- var location = Get("location");
- var planSku = Get("appServicePlanSku");
- if (string.IsNullOrWhiteSpace(planSku)) planSku = ConfigConstants.DefaultAppServicePlanSku;
-
- var deploymentProjectPath = Get("deploymentProjectPath");
-
- bool needDeployment = CheckNeedDeployment(cfg);
- var skipInfra = blueprintOnly || !needDeployment;
- var externalHosting = !needDeployment && !blueprintOnly;
-
- if (!skipInfra)
- {
- // Azure hosting scenario – need full infra details
- if (new[] { subscriptionId, resourceGroup, planName, webAppName, location }.Any(string.IsNullOrWhiteSpace))
- {
- _logger.LogError(
- "Config missing required properties for Azure hosting. " +
- "Need subscriptionId, resourceGroup, appServicePlanName, webAppName, location.");
- return false;
- }
- }
- else
- {
- // Non-Azure hosting or --blueprint: no infra required
- if (string.IsNullOrWhiteSpace(subscriptionId))
- {
- _logger.LogWarning(
- "subscriptionId is not set. This is acceptable for blueprint-only or External hosting mode " +
- "as Azure infrastructure will not be provisioned.");
- }
- }
-
- // Detect project platform for appropriate runtime configuration
- var platform = Models.ProjectPlatform.DotNet; // Default fallback
- if (!string.IsNullOrWhiteSpace(deploymentProjectPath))
- {
- platform = _platformDetector.Detect(deploymentProjectPath);
- _logger.LogInformation("Detected project platform: {Platform}", platform);
- }
- else
- {
- _logger.LogWarning("No deploymentProjectPath specified, defaulting to .NET runtime");
- }
-
- _logger.LogInformation("Agent 365 Setup - Starting...");
- _logger.LogInformation("Subscription: {Sub}", subscriptionId);
- _logger.LogInformation("Resource Group: {RG}", resourceGroup);
- _logger.LogInformation("App Service Plan: {Plan}", planName);
- _logger.LogInformation("Web App: {App}", webAppName);
- _logger.LogInformation("Location: {Loc}", location);
- _logger.LogInformation("");
-
- // ========================================================================
- // Phase 0: Ensure Azure CLI is logged in with proper scope
- // ========================================================================
- if (!skipInfra)
- {
- _logger.LogInformation("==> [0/5] Verifying Azure CLI authentication");
-
- // Check if logged in
- var accountCheck = await _executor.ExecuteAsync("az", "account show", captureOutput: true, suppressErrorLogging: true);
- if (!accountCheck.Success)
- {
- _logger.LogInformation("Azure CLI not authenticated. Initiating login with management scope...");
- _logger.LogInformation("A browser window will open for authentication.");
-
- // Use standard login without scope parameter (more reliable)
- var loginResult = await _executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken);
-
- if (!loginResult.Success)
- {
- _logger.LogError("Azure CLI login failed. Please run manually: az login --scope https://management.core.windows.net//.default");
- return false;
- }
-
- _logger.LogInformation("Azure CLI login successful!");
-
- // Wait a moment for the login to fully complete
- await Task.Delay(2000, cancellationToken);
- }
- else
- {
- _logger.LogInformation("Azure CLI already authenticated");
- }
-
- // Verify we have the management scope - if not, try to acquire it
- _logger.LogInformation("Verifying access to Azure management resources...");
- var tokenCheck = await _executor.ExecuteAsync(
- "az",
- "account get-access-token --resource https://management.core.windows.net/ --query accessToken -o tsv",
- captureOutput: true,
- suppressErrorLogging: true,
- cancellationToken: cancellationToken);
-
- if (!tokenCheck.Success)
- {
- _logger.LogWarning("Unable to acquire management scope token. Attempting re-authentication...");
- _logger.LogInformation("A browser window will open for authentication.");
-
- // Try standard login first (more reliable than scope-specific login)
- var loginResult = await _executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken);
-
- if (!loginResult.Success)
- {
- _logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default");
- return false;
- }
-
- _logger.LogInformation("Azure CLI re-authentication successful!");
-
- // Wait a moment for the token cache to update
- await Task.Delay(2000, cancellationToken);
-
- // Verify management token is now available
- var retryTokenCheck = await _executor.ExecuteAsync(
- "az",
- "account get-access-token --resource https://management.core.windows.net/ --query accessToken -o tsv",
- captureOutput: true,
- suppressErrorLogging: true,
- cancellationToken: cancellationToken);
-
- if (!retryTokenCheck.Success)
- {
- _logger.LogWarning("Still unable to acquire management scope token after re-authentication.");
- _logger.LogWarning("Continuing anyway - you may encounter permission errors later.");
- }
- else
- {
- _logger.LogInformation("Management scope token acquired successfully!");
- }
- }
- else
- {
- _logger.LogInformation("Management scope verified successfully");
- }
-
- _logger.LogInformation("");
- }
- else
- {
- _logger.LogInformation("==> [0/5] Skipping Azure management authentication (blueprint-only or External hosting)");
- }
-
- // ========================================================================
- // Phase 1: Deploy Agent runtime (App Service) + System-assigned Managed Identity
- // ========================================================================
- string? principalId = null;
- JsonObject generatedConfig = new JsonObject();
-
- if (skipInfra)
- {
- // Covers BOTH:
- // - blueprintOnly == true
- // - hostingMode == External (non-Azure)
- var modeMessage = blueprintOnly ? "--blueprint mode" : "External hosting (non-Azure)";
-
- _logger.LogInformation("==> [1/5] Skipping Azure infrastructure ({Mode})", modeMessage);
- _logger.LogInformation("Loading existing configuration...");
-
- // Load existing generated config if available
- if (File.Exists(generatedConfigPath))
- {
- try
- {
- generatedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath, cancellationToken))?.AsObject() ?? new JsonObject();
-
- if (blueprintOnly && generatedConfig.TryGetPropertyValue("managedIdentityPrincipalId", out var existingPrincipalId))
- {
- // Only reuse MSI in blueprint-only mode
- principalId = existingPrincipalId?.GetValue();
- _logger.LogInformation("Found existing Managed Identity Principal ID: {Id}", principalId ?? "(none)");
- }
- else if (externalHosting)
- {
- _logger.LogInformation("External hosting selected - Managed Identity will NOT be used.");
-
- // Make sure we don't create FIC later
- principalId = null;
- }
-
- _logger.LogInformation("Existing configuration loaded successfully");
- }
- catch (Exception ex)
- {
- _logger.LogWarning("Could not load existing config: {Message}. Starting fresh.", ex.Message);
- }
- }
- else
- {
- _logger.LogInformation("No existing configuration found - blueprint will be created without managed identity");
- }
-
- _logger.LogInformation("");
- }
- else
- {
- _logger.LogInformation("==> [1/5] Deploying App Service + enabling Managed Identity");
-
- // Set subscription context
- try
- {
- await _executor.ExecuteAsync("az", $"account set --subscription {subscriptionId}");
- }
- catch (Exception)
- {
- _logger.LogWarning("Failed to set az subscription context explicitly");
- }
-
- // Resource group
- var rgExists = await _executor.ExecuteAsync("az", $"group exists -n {resourceGroup} --subscription {subscriptionId}", captureOutput: true);
- if (rgExists.Success && rgExists.StandardOutput.Trim().Equals("true", StringComparison.OrdinalIgnoreCase))
- {
- _logger.LogInformation("Resource group already exists: {RG} (skipping creation)", resourceGroup);
- }
- else
- {
- _logger.LogInformation("Creating resource group {RG}", resourceGroup);
- await AzWarnAsync($"group create -n {resourceGroup} -l {location} --subscription {subscriptionId}", "Create resource group");
- }
-
- // App Service plan
- var planShow = await _executor.ExecuteAsync("az", $"appservice plan show -g {resourceGroup} -n {planName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true);
- if (planShow.Success)
- {
- _logger.LogInformation("App Service plan already exists: {Plan} (skipping creation)", planName);
- }
- else
- {
- _logger.LogInformation("Creating App Service plan {Plan}", planName);
- await AzWarnAsync($"appservice plan create -g {resourceGroup} -n {planName} --sku {planSku} --is-linux --subscription {subscriptionId}", "Create App Service plan");
- }
-
- // Web App
- var webShow = await _executor.ExecuteAsync("az", $"webapp show -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}", captureOutput: true);
- if (!webShow.Success)
- {
- var runtime = GetRuntimeForPlatform(platform);
- _logger.LogInformation("Creating web app {App} with runtime {Runtime}", webAppName, runtime);
- var createResult = await _executor.ExecuteAsync("az", $"webapp create -g {resourceGroup} -p {planName} -n {webAppName} --runtime \"{runtime}\" --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true);
- if (!createResult.Success)
- {
- if (createResult.StandardError.Contains("AuthorizationFailed", StringComparison.OrdinalIgnoreCase))
- {
- throw new AzureResourceException("WebApp", webAppName, createResult.StandardError, true);
- }
- else
- {
- _logger.LogError("ERROR: Web app creation failed: {Err}", createResult.StandardError);
- throw new InvalidOperationException($"Failed to create web app '{webAppName}'. Setup cannot continue.");
- }
- }
- }
- else
- {
- var linuxFxVersion = GetLinuxFxVersionForPlatform(platform);
- _logger.LogInformation("Web app already exists: {App} (skipping creation)", webAppName);
- _logger.LogInformation("Configuring web app to use {Platform} runtime ({LinuxFxVersion})...", platform, linuxFxVersion);
- await AzWarnAsync($"webapp config set -g {resourceGroup} -n {webAppName} --linux-fx-version \"{linuxFxVersion}\" --subscription {subscriptionId}", "Configure runtime");
- }
-
- // Verify web app
- var verifyResult = await _executor.ExecuteAsync("az", $"webapp show -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true);
- if (!verifyResult.Success)
- {
- _logger.LogWarning("WARNING: Unable to verify web app via az webapp show.");
- }
- else
- {
- _logger.LogInformation("Verified web app presence.");
- }
-
- // Managed Identity
- _logger.LogInformation("Assigning (or confirming) system-assigned managed identity");
- var identity = await _executor.ExecuteAsync("az", $"webapp identity assign -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}");
- if (identity.Success)
- {
- try
- {
- var json = JsonDocument.Parse(identity.StandardOutput);
- principalId = json.RootElement.GetProperty("principalId").GetString();
- if (!string.IsNullOrEmpty(principalId))
- {
- _logger.LogInformation("Managed Identity principalId: {Id}", principalId);
- }
- }
- catch
- {
- // ignore parse error
- }
- }
- else if (identity.StandardError.Contains("already has a managed identity", StringComparison.OrdinalIgnoreCase) ||
- identity.StandardError.Contains("Conflict", StringComparison.OrdinalIgnoreCase))
- {
- _logger.LogInformation("Managed identity already assigned (ignoring conflict).");
- }
- else
- {
- _logger.LogWarning("WARNING: identity assign returned error: {Err}", identity.StandardError.Trim());
- }
-
- // Load or create generated config
- if (File.Exists(generatedConfigPath))
- {
- try
- {
- generatedConfig = JsonNode.Parse(await File.ReadAllTextAsync(generatedConfigPath, cancellationToken))?.AsObject() ?? new JsonObject();
- }
- catch
- {
- _logger.LogWarning("Could not parse existing generated config, starting fresh");
- }
- }
-
- if (!string.IsNullOrWhiteSpace(principalId))
- {
- generatedConfig["managedIdentityPrincipalId"] = principalId;
- await File.WriteAllTextAsync(generatedConfigPath, generatedConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), cancellationToken);
- _logger.LogInformation("Generated config updated with MSI principalId: {Id}", principalId);
- }
-
- _logger.LogInformation("Waiting 10 seconds to ensure Service Principal is fully propagated...");
- await Task.Delay(10000, cancellationToken);
-
- } // End of !blueprintOnly block
-
- // ========================================================================
- // Phase 2: Agent Application (Blueprint) + Consent
- // ========================================================================
- _logger.LogInformation("");
- _logger.LogInformation("==> [2/5] Creating Agent Blueprint");
-
- // CRITICAL: Grant AgentApplication.Create permission BEFORE creating blueprint
- // This replaces the PowerShell call to DelegatedAgentApplicationCreateConsent.ps1
- _logger.LogInformation("");
- _logger.LogInformation("==> [2.1/5] Ensuring AgentApplication.Create Permission");
- _logger.LogInformation("This permission is required to create Agent Blueprints");
-
- var consentResult = await EnsureDelegatedConsentWithRetriesAsync(tenantId, cancellationToken);
- if (!consentResult)
- {
- _logger.LogError("Failed to ensure AgentApplication.Create permission after multiple attempts");
- return false;
- }
-
- _logger.LogInformation("");
- _logger.LogInformation("==> [2.2/5] Creating Agent Blueprint Application");
-
- // Get required configuration values
- var agentBlueprintDisplayName = Get("agentBlueprintDisplayName");
- var agentIdentityDisplayName = Get("agentIdentityDisplayName");
-
- if (string.IsNullOrWhiteSpace(agentBlueprintDisplayName))
- {
- _logger.LogError("agentBlueprintDisplayName missing in configuration");
- return false;
- }
-
- try
- {
- // Validate that needDeployment and blueprintOnly are not both true
- if (needDeployment && blueprintOnly)
- {
- _logger.LogError("Invalid configuration: both needDeployment and blueprintOnly are true. This is not supported, as it may result in attempting to use a managed identity that was not created.");
- return false;
- }
-
- var useManagedIdentity = (needDeployment && !blueprintOnly) || blueprintOnly;
-
- // Create the agent blueprint using Graph API directly (no PowerShell)
- var blueprintResult = await CreateAgentBlueprintAsync(
- tenantId,
- agentBlueprintDisplayName,
- agentIdentityDisplayName,
- principalId,
- useManagedIdentity,
- generatedConfig,
- cfg,
- cancellationToken);
-
- if (!blueprintResult.success)
- {
- throw new InvalidOperationException("Failed to create agent blueprint");
- }
-
- var blueprintAppId = blueprintResult.appId;
- var blueprintObjectId = blueprintResult.objectId;
-
- _logger.LogInformation("Agent Blueprint Details:");
- _logger.LogInformation(" - Display Name: {Name}", agentBlueprintDisplayName);
- _logger.LogInformation(" - App ID: {Id}", blueprintAppId);
- _logger.LogInformation(" - Object ID: {Id}", blueprintObjectId);
- _logger.LogInformation(" - Identifier URI: api://{Id}", blueprintAppId);
-
- // Convert to camelCase and save
- var camelCaseConfig = new JsonObject
- {
- ["managedIdentityPrincipalId"] = generatedConfig["managedIdentityPrincipalId"]?.DeepClone(),
- ["agentBlueprintId"] = blueprintAppId,
- ["agentBlueprintObjectId"] = blueprintObjectId,
- ["displayName"] = agentBlueprintDisplayName,
- ["servicePrincipalId"] = blueprintResult.servicePrincipalId,
- ["identifierUri"] = $"api://{blueprintAppId}",
- ["tenantId"] = tenantId
- };
-
- await File.WriteAllTextAsync(generatedConfigPath, camelCaseConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), cancellationToken);
- generatedConfig = camelCaseConfig;
-
- // ========================================================================
- // Phase 2.5: Create Client Secret for Agent Blueprint
- // ========================================================================
- _logger.LogInformation("");
- _logger.LogInformation("==> [2.5/5] Creating Client Secret for Agent Blueprint");
-
- await CreateBlueprintClientSecretAsync(blueprintObjectId!, blueprintAppId!, generatedConfig, generatedConfigPath, cancellationToken);
-
- }
- catch (Exception ex) when (ex is not Agent365Exception)
- {
- _logger.LogError(ex, "Failed to create agent blueprint: {Message}", ex.Message);
- return false;
- }
-
- // ====================================
- // Phase 3: MCP Server API Permissions
- // ====================================
- _logger.LogInformation("");
- _logger.LogInformation("==> [3/5] Adding MCP Server API Permissions to Blueprint");
-
- var blueprintAppIdForMcp = generatedConfig["agentBlueprintId"]?.GetValue();
- var blueprintObjectIdForMcp = generatedConfig["agentBlueprintObjectId"]?.GetValue();
-
- if (!string.IsNullOrWhiteSpace(blueprintAppIdForMcp) && !string.IsNullOrWhiteSpace(blueprintObjectIdForMcp))
- {
- await ConfigureMcpServerPermissionsAsync(cfg, generatedConfig, blueprintAppIdForMcp!, blueprintObjectIdForMcp!, tenantId, cancellationToken);
- }
-
- // ========================================================================
- // Phase 4: Configure Inheritable Permissions (matching PowerShell Step 6)
- // ========================================================================
- _logger.LogInformation("");
- _logger.LogInformation("==> [4/5] Configuring Inheritable Permissions for Agent Identities");
-
- if (!string.IsNullOrWhiteSpace(blueprintObjectIdForMcp))
- {
- await ConfigureInheritablePermissionsAsync(tenantId, generatedConfig, cfg, cancellationToken);
- }
- else
- {
- _logger.LogWarning("Blueprint Object ID not available, skipping inheritable permissions configuration");
- }
-
- // ========================================================================
- // Phase 5: Finalization
- // ========================================================================
- _logger.LogInformation("");
- _logger.LogInformation("==> [5/5] Finalizing Setup");
-
- generatedConfig["completed"] = true;
- generatedConfig["completedAt"] = DateTime.UtcNow.ToString("o");
- await File.WriteAllTextAsync(generatedConfigPath, generatedConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), cancellationToken);
-
- _logger.LogDebug("Generated config saved at: {Path}", generatedConfigPath);
- _logger.LogInformation("");
- _logger.LogInformation("Blueprint installation completed successfully");
- _logger.LogInformation("Blueprint ID: {BlueprintId}", generatedConfig["agentBlueprintId"]?.GetValue());
- _logger.LogInformation("Identifier URI: api://{AppId}", generatedConfig["agentBlueprintId"]?.GetValue());
-
- return true;
- }
-
- ///
- /// Create Agent Blueprint using Microsoft Graph API
- ///
- /// IMPORTANT: This requires interactive authentication with Application.ReadWrite.All permission.
- /// Uses the same authentication flow as Connect-MgGraph in PowerShell.
- ///
- private async Task<(bool success, string? appId, string? objectId, string? servicePrincipalId)> CreateAgentBlueprintAsync(
- string tenantId,
- string displayName,
- string? agentIdentityDisplayName,
- string? managedIdentityPrincipalId,
- bool useManagedIdentity,
- JsonObject generatedConfig,
- JsonObject setupConfig,
- CancellationToken ct)
- {
- try
- {
- _logger.LogInformation("Creating Agent Blueprint using Microsoft Graph SDK...");
-
- GraphServiceClient graphClient;
- try
- {
- graphClient = await GetAuthenticatedGraphClientAsync(tenantId, ct);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to get authenticated Graph client: {Message}", ex.Message);
- return (false, null, null, null);
- }
-
- // Get current user for sponsors field (mimics PowerShell script behavior)
- string? sponsorUserId = null;
- try
- {
- var me = await graphClient.Me.GetAsync(cancellationToken: ct);
- if (me != null && !string.IsNullOrEmpty(me.Id))
- {
- sponsorUserId = me.Id;
- _logger.LogInformation("Current user: {DisplayName} <{UPN}>", me.DisplayName, me.UserPrincipalName);
- _logger.LogInformation("Sponsor: https://graph.microsoft.com/v1.0/users/{UserId}", sponsorUserId);
- }
- }
- catch (Exception ex)
- {
- _logger.LogWarning("Could not retrieve current user for sponsors field: {Message}", ex.Message);
- }
-
- // Define the application manifest with @odata.type for Agent Identity Blueprint
- var appManifest = new JsonObject
- {
- ["@odata.type"] = "Microsoft.Graph.AgentIdentityBlueprint", // CRITICAL: Required for Agent Blueprint type
- ["displayName"] = displayName,
- ["signInAudience"] = "AzureADMultipleOrgs" // Multi-tenant
- };
-
- // Add sponsors field if we have the current user (PowerShell script includes this)
- if (!string.IsNullOrEmpty(sponsorUserId))
- {
- appManifest["sponsors@odata.bind"] = new JsonArray
- {
- $"https://graph.microsoft.com/v1.0/users/{sponsorUserId}"
- };
- }
-
- // Create the application using Microsoft Graph SDK
- using var httpClient = new HttpClient();
- var graphToken = await GetTokenFromGraphClient(graphClient, tenantId);
- if (string.IsNullOrEmpty(graphToken))
- {
- _logger.LogError("Failed to extract access token from Graph client");
- return (false, null, null, null);
- }
-
- httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken);
- httpClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual");
- httpClient.DefaultRequestHeaders.Add("OData-Version", "4.0"); // Required for @odata.type
-
- var createAppUrl = "https://graph.microsoft.com/beta/applications";
-
- _logger.LogInformation("Creating Agent Blueprint application...");
- _logger.LogInformation(" - Display Name: {DisplayName}", displayName);
- if (!string.IsNullOrEmpty(sponsorUserId))
- {
- _logger.LogInformation(" - Sponsor: User ID {UserId}", sponsorUserId);
- }
-
- var appResponse = await httpClient.PostAsync(
- createAppUrl,
- new StringContent(appManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
- ct);
-
- if (!appResponse.IsSuccessStatusCode)
- {
- var errorContent = await appResponse.Content.ReadAsStringAsync(ct);
-
- // If sponsors field causes error (Bad Request 400), retry without it
- if (appResponse.StatusCode == System.Net.HttpStatusCode.BadRequest &&
- !string.IsNullOrEmpty(sponsorUserId))
- {
- _logger.LogWarning("Agent Blueprint creation with sponsors failed (Bad Request). Retrying without sponsors...");
-
- // Remove sponsors field and retry
- appManifest.Remove("sponsors@odata.bind");
-
- appResponse = await httpClient.PostAsync(
- createAppUrl,
- new StringContent(appManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
- ct);
-
- if (!appResponse.IsSuccessStatusCode)
- {
- errorContent = await appResponse.Content.ReadAsStringAsync(ct);
- _logger.LogError("Failed to create application (fallback): {Status} - {Error}", appResponse.StatusCode, errorContent);
- return (false, null, null, null);
- }
- }
- else
- {
- _logger.LogError("Failed to create application: {Status} - {Error}", appResponse.StatusCode, errorContent);
- return (false, null, null, null);
- }
- }
-
- var appJson = await appResponse.Content.ReadAsStringAsync(ct);
- var app = JsonNode.Parse(appJson)!.AsObject();
- var appId = app["appId"]!.GetValue();
- var objectId = app["id"]!.GetValue();
-
- _logger.LogInformation("Application created successfully");
- _logger.LogInformation(" - App ID: {AppId}", appId);
- _logger.LogInformation(" - Object ID: {ObjectId}", objectId);
-
- // Wait for application propagation
- const int maxRetries = 30;
- const int delayMs = 4000;
- bool appAvailable = false;
- for (int i = 0; i < maxRetries; i++)
- {
- var checkResp = await httpClient.GetAsync($"https://graph.microsoft.com/v1.0/applications/{objectId}", ct);
- if (checkResp.IsSuccessStatusCode)
- {
- appAvailable = true;
- break;
- }
- _logger.LogInformation("Waiting for application object to be available in directory (attempt {Attempt}/{Max})...", i + 1, maxRetries);
- await Task.Delay(delayMs, ct);
- }
-
- if (!appAvailable)
- {
- _logger.LogError("App object not available after creation. Aborting setup.");
- return (false, null, null, null);
- }
-
- // Update application with identifier URI
- var identifierUri = $"api://{appId}";
- var patchAppUrl = $"https://graph.microsoft.com/v1.0/applications/{objectId}";
- var patchBody = new JsonObject
- {
- ["identifierUris"] = new JsonArray { identifierUri }
- };
-
- var patchResponse = await httpClient.PatchAsync(
- patchAppUrl,
- new StringContent(patchBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
- ct);
-
- if (!patchResponse.IsSuccessStatusCode)
- {
- var patchError = await patchResponse.Content.ReadAsStringAsync(ct);
- _logger.LogInformation("Waiting for application propagation before setting identifier URI...");
- _logger.LogDebug("Identifier URI update deferred (propagation delay): {Error}", patchError);
- }
- else
- {
- _logger.LogInformation("Identifier URI set to: {Uri}", identifierUri);
- }
-
- // Create service principal
- _logger.LogInformation("Creating service principal...");
-
- var spManifest = new JsonObject
- {
- ["appId"] = appId
- };
-
- var createSpUrl = "https://graph.microsoft.com/v1.0/servicePrincipals";
- var spResponse = await httpClient.PostAsync(
- createSpUrl,
- new StringContent(spManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
- ct);
-
- string? servicePrincipalId = null;
- if (spResponse.IsSuccessStatusCode)
- {
- var spJson = await spResponse.Content.ReadAsStringAsync(ct);
- var sp = JsonNode.Parse(spJson)!.AsObject();
- servicePrincipalId = sp["id"]!.GetValue();
- _logger.LogInformation("Service principal created: {SpId}", servicePrincipalId);
- }
- else
- {
- var spError = await spResponse.Content.ReadAsStringAsync(ct);
- _logger.LogInformation("Waiting for application propagation before creating service principal...");
- _logger.LogDebug("Service principal creation deferred (propagation delay): {Error}", spError);
- }
-
- // Wait for service principal propagation
- _logger.LogInformation("Waiting 10 seconds to ensure Service Principal is fully propagated...");
- await Task.Delay(10000, ct);
-
- // Create Federated Identity Credential ONLY when MSI is relevant (if managed identity provided)
- if (useManagedIdentity && !string.IsNullOrWhiteSpace(managedIdentityPrincipalId))
- {
- _logger.LogInformation("Creating Federated Identity Credential for Managed Identity...");
- var credentialName = $"{displayName.Replace(" ", "")}-MSI";
-
- var ficSuccess = await CreateFederatedIdentityCredentialAsync(
- tenantId,
- objectId,
- credentialName,
- managedIdentityPrincipalId,
- graphToken,
- ct);
-
- if (ficSuccess)
- {
- _logger.LogInformation("Federated Identity Credential created successfully");
- }
- else
- {
- _logger.LogWarning("Failed to create Federated Identity Credential");
- }
- }
- else if (!useManagedIdentity)
- {
- _logger.LogInformation("Skipping Federated Identity Credential creation (external hosting / no MSI configured)");
- }
- else
- {
- _logger.LogInformation("Skipping Federated Identity Credential creation (no MSI Principal ID provided)");
- }
-
- // Request admin consent
- _logger.LogInformation("Requesting admin consent for application");
-
- // Get application scopes from config (fallback to hardcoded defaults)
- var applicationScopes = new List();
- if (setupConfig.TryGetPropertyValue("agentApplicationScopes", out var appScopesNode) &&
- appScopesNode is JsonArray appScopesArr)
- {
- _logger.LogInformation(" Found 'agentApplicationScopes' in config");
- foreach (var scopeItem in appScopesArr)
- {
- var scope = scopeItem?.GetValue();
- if (!string.IsNullOrWhiteSpace(scope))
- {
- applicationScopes.Add(scope);
- }
- }
- }
- else
- {
- _logger.LogInformation(" 'agentApplicationScopes' not found in config, using hardcoded defaults");
- applicationScopes.AddRange(ConfigConstants.DefaultAgentApplicationScopes);
- }
-
- // Final fallback (should not happen with proper defaults)
- if (applicationScopes.Count == 0)
- {
- _logger.LogWarning("No application scopes available, falling back to User.Read");
- applicationScopes.Add("User.Read");
- }
-
- _logger.LogInformation(" - Application scopes: {Scopes}", string.Join(", ", applicationScopes));
-
- // Generate consent URLs for Graph and Connectivity
- var applicationScopesJoined = string.Join(' ', applicationScopes);
- var consentUrlGraph = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={appId}&scope={Uri.EscapeDataString(applicationScopesJoined)}&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123";
- var consentUrlConnectivity = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={appId}&scope=0ddb742a-e7dc-4899-a31e-80e797ec7144/Connectivity.Connections.Read&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123";
-
- _logger.LogInformation("Opening browser for Graph API admin consent...");
- TryOpenBrowser(consentUrlGraph);
-
- var consent1Success = await AdminConsentHelper.PollAdminConsentAsync(_executor, _logger, appId, "Graph API Scopes", 180, 5, ct);
-
- if (consent1Success)
- {
- _logger.LogInformation("Graph API admin consent granted successfully!");
- }
- else
- {
- _logger.LogWarning("Graph API admin consent may not have completed");
- }
-
- _logger.LogInformation("");
- _logger.LogInformation("Opening browser for Connectivity admin consent...");
- TryOpenBrowser(consentUrlConnectivity);
-
- var consent2Success = await AdminConsentHelper.PollAdminConsentAsync(_executor, _logger, appId, "Connectivity Scope", 180, 5, ct);
-
- if (consent2Success)
- {
- _logger.LogInformation("Connectivity admin consent granted successfully!");
- }
- else
- {
- _logger.LogWarning("Connectivity admin consent may not have completed");
- }
-
- // Save consent URLs and status to generated config
- generatedConfig["consentUrlGraph"] = consentUrlGraph;
- generatedConfig["consentUrlConnectivity"] = consentUrlConnectivity;
- generatedConfig["consent1Granted"] = consent1Success;
- generatedConfig["consent2Granted"] = consent2Success;
-
- if (!consent1Success || !consent2Success)
- {
- _logger.LogWarning("");
- _logger.LogWarning("One or more consents may not have been detected");
- _logger.LogWarning("The setup will continue, but you may need to grant consent manually.");
- _logger.LogWarning("Consent URL (Graph): {Url}", consentUrlGraph);
- _logger.LogWarning("Consent URL (Connectivity): {Url}", consentUrlConnectivity);
- }
-
- return (true, appId, objectId, servicePrincipalId);
- }
- catch (Exception ex) when (ex is not Agent365Exception)
- {
- _logger.LogError(ex, "Failed to create agent blueprint: {Message}", ex.Message);
- return (false, null, null, null);
- }
- }
-
- ///
- /// Create Federated Identity Credential to link managed identity to blueprint
- /// Equivalent to createFederatedIdentityCredential function in PowerShell
- /// Implements retry logic to handle Azure AD propagation delays
- ///
- private async Task CreateFederatedIdentityCredentialAsync(
- string tenantId,
- string blueprintObjectId,
- string credentialName,
- string msiPrincipalId,
- string graphToken,
- CancellationToken ct)
- {
- const int maxRetries = 5;
- const int initialDelayMs = 2000; // Start with 2 seconds
-
- try
- {
- if (string.IsNullOrWhiteSpace(graphToken))
- {
- _logger.LogError("Graph token is required for FIC creation");
- return false;
- }
-
- var federatedCredential = new JsonObject
- {
- ["name"] = credentialName,
- ["issuer"] = $"https://login.microsoftonline.com/{tenantId}/v2.0",
- ["subject"] = msiPrincipalId,
- ["audiences"] = new JsonArray { "api://AzureADTokenExchange" }
- };
-
- using var httpClient = new HttpClient();
- httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken);
- httpClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual");
-
- // Try standard endpoint first, then fallback to Agent Blueprint-specific path
- var urls = new[]
- {
- $"https://graph.microsoft.com/beta/applications/{blueprintObjectId}/federatedIdentityCredentials",
- $"https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/federatedIdentityCredentials"
- };
-
- string? lastError = null;
-
- foreach (var url in urls)
- {
- // Retry loop to handle propagation delays
- for (int attempt = 1; attempt <= maxRetries; attempt++)
- {
- var response = await httpClient.PostAsync(
- url,
- new StringContent(federatedCredential.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
- ct);
-
- if (response.IsSuccessStatusCode)
- {
- _logger.LogInformation(" - Credential Name: {Name}", credentialName);
- _logger.LogInformation(" - Issuer: https://login.microsoftonline.com/{TenantId}/v2.0", tenantId);
- _logger.LogInformation(" - Subject (MSI Principal ID): {MsiId}", msiPrincipalId);
- return true;
- }
-
- var error = await response.Content.ReadAsStringAsync(ct);
- lastError = error;
-
- // Check if it's a propagation issue (resource not found)
- if ((error.Contains("Request_ResourceNotFound") || error.Contains("does not exist")) && attempt < maxRetries)
- {
- var delayMs = initialDelayMs * (int)Math.Pow(2, attempt - 1); // Exponential backoff
- _logger.LogWarning("Application object not yet propagated (attempt {Attempt}/{MaxRetries}). Retrying in {Delay}ms...",
- attempt, maxRetries, delayMs);
- await Task.Delay(delayMs, ct);
- continue;
- }
-
- // Check if it's an Agent Blueprint API version error - try fallback URL
- if (error.Contains("Agent Blueprints are not supported on the API version"))
- {
- _logger.LogDebug("Standard endpoint not supported, trying Agent Blueprint-specific path...");
- break; // Break retry loop to try next URL
- }
-
- // Other error - break retry loop to try next URL
- _logger.LogDebug("FIC creation failed with error: {Error}", error);
- break;
- }
- }
-
- // All attempts failed
- _logger.LogDebug("Failed to create federated identity credential after trying all endpoints (may not be supported for Agent Blueprints yet): {Error}", lastError);
- return false;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Exception creating federated identity credential: {Message}", ex.Message);
- return false;
- }
- }
-
- ///
- /// Configure MCP server API permissions (Step 6.5 from PowerShell script).
- /// This was missing in the original C# implementation.
- ///
- private async Task ConfigureMcpServerPermissionsAsync(
- JsonObject setupConfig,
- JsonObject generatedConfig,
- string blueprintAppId,
- string blueprintObjectId,
- string tenantId,
- CancellationToken ct)
- {
- try
- {
- // Read ToolingManifest.json
- string? toolingManifestPath = null;
- var deploymentProjectPath = setupConfig["deploymentProjectPath"]?.GetValue();
-
- if (!string.IsNullOrWhiteSpace(deploymentProjectPath))
- {
- toolingManifestPath = Path.Combine(deploymentProjectPath, "ToolingManifest.json");
- _logger.LogInformation("Looking for ToolingManifest.json in deployment project path: {Path}", toolingManifestPath);
- }
- else
- {
- var scriptDir = Path.GetDirectoryName(Path.GetFullPath(setupConfig.ToJsonString())) ?? Environment.CurrentDirectory;
- toolingManifestPath = Path.Combine(scriptDir, "ToolingManifest.json");
- _logger.LogInformation("Looking for ToolingManifest.json in script directory: {Path}", toolingManifestPath);
- }
-
- if (!File.Exists(toolingManifestPath))
- {
- _logger.LogInformation("ToolingManifest.json not found - skipping MCP API permissions");
- return;
- }
-
- var manifest = JsonNode.Parse(await File.ReadAllTextAsync(toolingManifestPath, ct))!.AsObject();
-
- if (!manifest.TryGetPropertyValue("mcpServers", out var serversNode) || serversNode is not JsonArray servers || servers.Count == 0)
- {
- _logger.LogInformation("No MCP servers found in ToolingManifest.json");
- return;
- }
-
- var audienceGroups = new Dictionary>();
-
- // Group servers by audience
- foreach (var server in servers)
- {
- var serverObj = server?.AsObject();
- if (serverObj == null) continue;
-
- var scope = serverObj["scope"]?.GetValue();
- var audience = serverObj["audience"]?.GetValue();
-
- if (string.IsNullOrWhiteSpace(scope) || string.IsNullOrWhiteSpace(audience))
- continue;
-
- // Extract app ID from audience (remove "api://" prefix)
- var mcpAppId = audience.Replace("api://", "");
-
- // Validate GUID format
- if (!Guid.TryParse(mcpAppId, out _))
- {
- _logger.LogWarning("Skipping MCP server - invalid audience format: {Audience} (not a valid App ID)", audience);
- continue;
- }
-
- if (!audienceGroups.ContainsKey(mcpAppId))
- {
- audienceGroups[mcpAppId] = new List();
- }
-
- if (!audienceGroups[mcpAppId].Contains(scope))
- {
- audienceGroups[mcpAppId].Add(scope);
- }
-
- _logger.LogInformation(" Found MCP scope: {Scope} for audience: {Audience}", scope, audience);
- }
-
- if (audienceGroups.Count == 0)
- {
- _logger.LogInformation(" No MCP API permissions found to add");
- return;
- }
-
- // Note: Agentic Applications don't support RequiredResourceAccess property
- // Skip updating the application with MCP API permissions, but still request admin consent
- _logger.LogInformation(" Skipping MCP API permissions update (not supported for Agentic Applications)");
- _logger.LogInformation(" Will request admin consent directly for MCP scopes");
-
- // Build consent URL for all MCP scopes
- var mcpConsentScopes = new List();
- foreach (var (appId, scopes) in audienceGroups)
- {
- foreach (var scope in scopes)
- {
- mcpConsentScopes.Add($"{appId}/{scope}");
- }
- }
-
- if (mcpConsentScopes.Count > 0)
- {
- var scopesJoined = string.Join(' ', mcpConsentScopes);
- var consentUrlMcp = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={blueprintAppId}&scope={Uri.EscapeDataString(scopesJoined)}&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123";
-
- _logger.LogInformation(" Opening browser for MCP server admin consent...");
- TryOpenBrowser(consentUrlMcp);
-
- var consentMcpSuccess = await AdminConsentHelper.PollAdminConsentAsync(_executor, _logger, blueprintAppId, "MCP Server Scopes", 180, 5, ct);
-
- if (consentMcpSuccess)
- {
- _logger.LogInformation(" MCP server admin consent granted successfully!");
- }
- else
- {
- _logger.LogWarning(" WARNING: MCP server admin consent may not have completed");
- }
-
- generatedConfig["agentIdentityConsentUrlMcp"] = consentUrlMcp;
- generatedConfig["consentMcpGranted"] = consentMcpSuccess;
- }
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "WARNING: Failed to add MCP API permissions: {Message}", ex.Message);
- _logger.LogInformation(" Continuing with Blueprint setup...");
- }
- }
-
- ///
- /// Create a client secret for the Agent Blueprint using Microsoft Graph API.
- /// The secret is encrypted using DPAPI on Windows before storage.
- ///
- private async Task CreateBlueprintClientSecretAsync(
- string blueprintObjectId,
- string blueprintAppId,
- JsonObject generatedConfig,
- string generatedConfigPath,
- CancellationToken ct)
- {
- try
- {
- _logger.LogInformation("Creating client secret for Agent Blueprint using Graph API...");
-
- // Get Graph access token
- var graphToken = await _graphService.GetGraphAccessTokenAsync(generatedConfig["tenantId"]?.GetValue() ?? string.Empty, ct);
-
- if (string.IsNullOrWhiteSpace(graphToken))
- {
- _logger.LogError("Failed to acquire Graph API access token");
- throw new InvalidOperationException("Cannot create client secret without Graph API token");
- }
-
- using var httpClient = new HttpClient();
- httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken);
-
- // Create password credential (client secret)
- var secretBody = new JsonObject
- {
- ["passwordCredential"] = new JsonObject
- {
- ["displayName"] = "Agent 365 CLI Generated Secret",
- ["endDateTime"] = DateTime.UtcNow.AddYears(2).ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
- }
- };
-
- var addPasswordUrl = $"https://graph.microsoft.com/v1.0/applications/{blueprintObjectId}/addPassword";
- var passwordResponse = await httpClient.PostAsync(
- addPasswordUrl,
- new StringContent(secretBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
- ct);
-
- if (!passwordResponse.IsSuccessStatusCode)
- {
- var errorContent = await passwordResponse.Content.ReadAsStringAsync(ct);
- _logger.LogError("Failed to create client secret: {Status} - {Error}", passwordResponse.StatusCode, errorContent);
- throw new InvalidOperationException($"Failed to create client secret: {errorContent}");
- }
-
- var passwordJson = await passwordResponse.Content.ReadAsStringAsync(ct);
- var passwordResult = JsonNode.Parse(passwordJson)!.AsObject();
-
- // Extract and immediately encrypt the secret (no plaintext variable)
- var secretTextNode = passwordResult["secretText"];
- if (secretTextNode == null || string.IsNullOrWhiteSpace(secretTextNode.GetValue()))
- {
- _logger.LogError("Client secret text is empty in response");
- throw new InvalidOperationException("Client secret creation returned empty secret");
- }
-
- // Encrypt immediately without intermediate plaintext storage
- var protectedSecret = ProtectSecret(secretTextNode.GetValue());
-
- // Store the encrypted client secret in generated config using camelCase
- generatedConfig["agentBlueprintClientSecret"] = protectedSecret;
- generatedConfig["agentBlueprintClientSecretProtected"] = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
-
- await File.WriteAllTextAsync(
- generatedConfigPath,
- generatedConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }),
- ct);
-
- _logger.LogInformation("Client secret created successfully!");
- _logger.LogInformation(" - Secret stored in generated config (encrypted: {IsProtected})", RuntimeInformation.IsOSPlatform(OSPlatform.Windows));
- _logger.LogWarning("IMPORTANT: The client secret has been stored in {Path}", generatedConfigPath);
- _logger.LogWarning("Keep this file secure and do not commit it to source control!");
-
- if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- _logger.LogWarning("WARNING: Secret encryption is only available on Windows. The secret is stored in plaintext.");
- _logger.LogWarning("Consider using environment variables or Azure Key Vault for production deployments.");
- }
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to create client secret: {Message}", ex.Message);
- _logger.LogInformation("You can create a client secret manually:");
- _logger.LogInformation(" 1. Go to Azure Portal > App Registrations");
- _logger.LogInformation(" 2. Find your Agent Blueprint: {AppId}", blueprintAppId);
- _logger.LogInformation(" 3. Navigate to Certificates & secrets > Client secrets");
- _logger.LogInformation(" 4. Click 'New client secret' and save the value");
- _logger.LogInformation(" 5. Add it to {Path} as 'agentBlueprintClientSecret'", generatedConfigPath);
- }
- }
-
- ///
- /// Protects (encrypts) a secret string using DPAPI on Windows.
- /// On non-Windows platforms, returns the plaintext with a warning.
- ///
- /// The secret to protect
- /// Base64-encoded encrypted secret on Windows, plaintext on other platforms
- private string ProtectSecret(string plaintext)
- {
- if (string.IsNullOrWhiteSpace(plaintext))
- {
- return plaintext;
- }
-
- try
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- // Use Windows DPAPI to encrypt the secret
- var plaintextBytes = System.Text.Encoding.UTF8.GetBytes(plaintext);
- var protectedBytes = ProtectedData.Protect(
- plaintextBytes,
- optionalEntropy: null,
- scope: DataProtectionScope.CurrentUser);
-
- // Return as base64-encoded string
- return Convert.ToBase64String(protectedBytes);
- }
- else
- {
- // On non-Windows platforms, we cannot use DPAPI
- // Return plaintext and rely on file system permissions
- _logger.LogWarning("DPAPI encryption not available on this platform. Secret will be stored in plaintext.");
- return plaintext;
- }
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to encrypt secret, storing in plaintext: {Message}", ex.Message);
- return plaintext;
- }
- }
-
- private async Task AzWarnAsync(string args, string description)
- {
- var result = await _executor.ExecuteAsync("az", args, suppressErrorLogging: true);
- if (!result.Success)
- {
- if (result.StandardError.Contains("already exists", StringComparison.OrdinalIgnoreCase))
- {
- _logger.LogInformation("{Description} already exists (skipping creation)", description);
- }
- else if (result.StandardError.Contains("AuthorizationFailed", StringComparison.OrdinalIgnoreCase))
- {
- var exception = new AzureResourceException(description, string.Empty, result.StandardError, true);
- ExceptionHandler.HandleAgent365Exception(exception);
- }
- else
- {
- _logger.LogWarning("az {Description} returned non-success (exit code {Code}). Error: {Err}",
- description, result.ExitCode, Short(result.StandardError));
- }
- }
- }
-
- private 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
- }
- }
-
- private async Task ConfigureInheritablePermissionsAsync(
- string tenantId,
- JsonObject generatedConfig,
- JsonObject setupConfig,
- CancellationToken ct)
- {
- // Get the App Object ID from generatedConfig
- var blueprintObjectId = generatedConfig["agentBlueprintObjectId"]?.ToString();
- if (string.IsNullOrWhiteSpace(blueprintObjectId))
- {
- _logger.LogError("Blueprint Object ID missing in generated config.");
- throw new InvalidOperationException("Blueprint Object ID missing.");
- }
-
- // TODO: Detect 1P vs 3P agent blueprint. For now, assume 1P. Replace with real detection logic if available.
- bool is1p = true; // Placeholder: set to false for 3P, or add detection logic
-
- if (is1p)
- {
- // 1P: POST inheritable permissions to beta endpoint
- GraphServiceClient graphClient;
- try
- {
- graphClient = await GetAuthenticatedGraphClientAsync(tenantId, ct);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to get authenticated Graph client.");
- _logger.LogWarning("Authentication failed, skipping inheritable permissions configuration.");
- _logger.LogWarning("");
- _logger.LogWarning("MANUAL CONFIGURATION REQUIRED:");
- _logger.LogWarning(" You need to configure inheritable permissions manually in Azure Portal.");
- _logger.LogWarning(" {DocumentationMessage}", DocumentationMessage);
- _logger.LogWarning("");
-
- generatedConfig["inheritanceConfigured"] = false;
- generatedConfig["inheritanceConfigError"] = "Authentication failed: " + ex.Message;
- return;
- }
-
- var graphToken = await GetTokenFromGraphClient(graphClient, tenantId);
- if (string.IsNullOrWhiteSpace(graphToken))
- {
- _logger.LogError("Failed to acquire Graph API access token");
- _logger.LogWarning("Skipping inheritable permissions configuration");
- _logger.LogWarning("");
- _logger.LogWarning("MANUAL CONFIGURATION REQUIRED:");
- _logger.LogWarning(" You need to configure inheritable permissions manually in Azure Portal.");
- _logger.LogWarning(" {DocumentationMessage}", DocumentationMessage);
- _logger.LogWarning("");
-
- generatedConfig["inheritanceConfigured"] = false;
- generatedConfig["inheritanceConfigError"] = "Failed to acquire Graph API access token";
- return;
- }
-
- // Read scopes from a365.config.json
- var inheritableScopes = ReadInheritableScopesFromConfig(setupConfig);
-
- if (inheritableScopes.Count == 0)
- {
- _logger.LogInformation("No inheritable scopes found in configuration, skipping inheritable permissions");
- return;
- }
-
- _logger.LogInformation("Configuring inheritable permissions with {Count} scopes: {Scopes}",
- inheritableScopes.Count, string.Join(", ", inheritableScopes));
-
- using var httpClient = new HttpClient();
- httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken);
-
- // ===================================================================
- // Step 1: Configure Microsoft Graph inheritable permissions
- // ===================================================================
- var graphUrl = $"https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/inheritablePermissions";
-
- _logger.LogInformation("Configuring Graph inheritable permissions");
- _logger.LogDebug(" - Request URL: {Url}", graphUrl);
- _logger.LogDebug(" - Blueprint Object ID: {ObjectId}", blueprintObjectId);
-
- // Convert scope list to JsonArray
- var scopesArray = new JsonArray();
- foreach (var scope in inheritableScopes)
- {
- scopesArray.Add(scope);
- }
-
- var graphBody = new JsonObject
- {
- ["resourceAppId"] = GraphResourceAppId,
- ["inheritableScopes"] = new JsonObject
- {
- ["@odata.type"] = "microsoft.graph.enumeratedScopes",
- ["scopes"] = scopesArray
- }
- };
-
- _logger.LogDebug(" - Request body: {Body}", graphBody.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
-
- var graphResponse = await httpClient.PostAsync(
- graphUrl,
- new StringContent(graphBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
- ct);
-
- if (!graphResponse.IsSuccessStatusCode)
- {
- var error = await graphResponse.Content.ReadAsStringAsync(ct);
-
- bool isAlreadyConfigured =
- (error.Contains("already exists", StringComparison.OrdinalIgnoreCase) ||
- error.Contains("duplicate", StringComparison.OrdinalIgnoreCase)) ||
- graphResponse.StatusCode == System.Net.HttpStatusCode.Conflict;
-
- if (isAlreadyConfigured)
- {
- _logger.LogInformation(" - Graph inheritable permissions already configured (idempotent)");
- }
- else
- {
- // Check if this is an authorization error
- bool isAuthorizationError =
- error.Contains("Authorization_RequestDenied", StringComparison.OrdinalIgnoreCase) ||
- error.Contains("Insufficient privileges", StringComparison.OrdinalIgnoreCase) ||
- graphResponse.StatusCode == System.Net.HttpStatusCode.Forbidden ||
- graphResponse.StatusCode == System.Net.HttpStatusCode.Unauthorized;
-
- if (isAuthorizationError)
- {
- _logger.LogError("Failed to configure Graph inheritable permissions: {Status} - {Error}",
- graphResponse.StatusCode, error);
- _logger.LogError("");
- _logger.LogError("=== INSUFFICIENT PERMISSIONS DETECTED ===");
- _logger.LogError("");
- _logger.LogError("The current user account does not have sufficient privileges to configure inheritable permissions.");
- _logger.LogError("");
- foreach (var scope in inheritableScopes)
- {
- _logger.LogError(" - {Scope}", scope);
- }
- _logger.LogError(" 5. Click 'Grant admin consent'");
- _logger.LogError("");
-
- generatedConfig["inheritanceConfigured"] = false;
- generatedConfig["graphInheritanceError"] = error;
- }
- else
- {
- _logger.LogError("Failed to configure Graph inheritable permissions: {Status} - {Error}",
- graphResponse.StatusCode, error);
- generatedConfig["inheritanceConfigured"] = false;
- generatedConfig["graphInheritanceError"] = error;
- }
- }
- }
- else
- {
- _logger.LogInformation("Successfully configured Graph inheritable permissions");
- _logger.LogInformation(" - Resource: Microsoft Graph");
- _logger.LogInformation(" - Scopes: {Scopes}", string.Join(", ", inheritableScopes));
- generatedConfig["graphInheritanceConfigured"] = true;
- }
-
- // ===================================================================
- // Step 2: Configure Connectivity inheritable permissions
- // ===================================================================
- var connectivityUrl = $"https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/inheritablePermissions";
-
- _logger.LogInformation("");
- _logger.LogInformation("Configuring Connectivity inheritable permissions");
- _logger.LogDebug(" - Request URL: {Url}", connectivityUrl);
-
- var connectivityBody = new JsonObject
- {
- ["resourceAppId"] = ConnectivityResourceAppId,
- ["inheritableScopes"] = new JsonObject
- {
- ["@odata.type"] = "microsoft.graph.enumeratedScopes",
- ["scopes"] = new JsonArray { "Connectivity.Connections.Read" }
- }
- };
-
- _logger.LogDebug(" - Request body: {Body}", connectivityBody.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
-
- var connectivityResponse = await httpClient.PostAsync(
- connectivityUrl,
- new StringContent(connectivityBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"),
- ct);
-
- if (!connectivityResponse.IsSuccessStatusCode)
- {
- var error = await connectivityResponse.Content.ReadAsStringAsync(ct);
-
- bool isAlreadyConfigured =
- (error.Contains("already exists", StringComparison.OrdinalIgnoreCase) ||
- error.Contains("duplicate", StringComparison.OrdinalIgnoreCase)) ||
- connectivityResponse.StatusCode == System.Net.HttpStatusCode.Conflict;
-
- if (isAlreadyConfigured)
- {
- _logger.LogInformation(" - Connectivity inheritable permissions already configured (idempotent)");
- }
- else
- {
- // Check if this is an authorization error
- bool isAuthorizationError =
- error.Contains("Authorization_RequestDenied", StringComparison.OrdinalIgnoreCase) ||
- error.Contains("Insufficient privileges", StringComparison.OrdinalIgnoreCase) ||
- connectivityResponse.StatusCode == System.Net.HttpStatusCode.Forbidden ||
- connectivityResponse.StatusCode == System.Net.HttpStatusCode.Unauthorized;
-
- if (isAuthorizationError)
- {
- _logger.LogError("Failed to configure Connectivity inheritable permissions: {Status} - {Error}",
- connectivityResponse.StatusCode, error);
- _logger.LogError("See the troubleshooting steps above for resolving permission issues.");
- generatedConfig["connectivityInheritanceError"] = error;
- }
- else
- {
- _logger.LogError("Failed to configure Connectivity inheritable permissions: {Status} - {Error}",
- connectivityResponse.StatusCode, error);
- generatedConfig["connectivityInheritanceError"] = error;
- }
- }
- }
- else
- {
- _logger.LogInformation("Successfully configured Connectivity inheritable permissions");
- _logger.LogInformation(" - Resource: Connectivity Service");
- _logger.LogInformation(" - Scope: Connectivity.Connections.Read");
- generatedConfig["connectivityInheritanceConfigured"] = true;
- }
-
- // Set overall inheritance configured status
- var bothSucceeded =
- (generatedConfig["graphInheritanceConfigured"]?.GetValue() ?? false) &&
- (generatedConfig["connectivityInheritanceConfigured"]?.GetValue() ?? false);
-
- generatedConfig["inheritanceConfigured"] = bothSucceeded;
-
- if (!bothSucceeded)
- {
- _logger.LogWarning("One or more inheritable permissions failed to configure");
- _logger.LogWarning("You may need to configure these manually in Azure Portal");
- }
- else
- {
- _logger.LogInformation("");
- _logger.LogInformation("All inheritable permissions configured successfully!");
- }
- }
- else
- {
- // 3P: Not supported yet
- _logger.LogWarning("Inheritable permissions configuration is not supported for 3P agent blueprints. Skipping.");
- // TODO: Implement 3P logic if/when supported
- }
- }
-
- ///
- /// Read inheritable scopes from a365.config.json
- /// Looks for 'agentIdentityScopes' property, falls back to hardcoded defaults
- ///
- private List ReadInheritableScopesFromConfig(JsonObject setupConfig)
- {
- var inheritableScopes = new List();
-
- try
- {
- _logger.LogInformation("Reading inheritable scopes from a365.config.json");
-
- // Try to read from agentIdentityScopes property in the setupConfig
- if (setupConfig.TryGetPropertyValue("agentIdentityScopes", out var agentIdentityScopesNode) &&
- agentIdentityScopesNode is JsonArray agentIdentityScopesArr)
- {
- _logger.LogInformation(" Found 'agentIdentityScopes' property in config");
-
- foreach (var scopeItem in agentIdentityScopesArr)
- {
- var scope = scopeItem?.GetValue();
- if (!string.IsNullOrWhiteSpace(scope) && !inheritableScopes.Contains(scope))
- {
- inheritableScopes.Add(scope);
- _logger.LogInformation(" Found inheritable scope: {Scope}", scope);
- }
- }
- }
- else
- {
- _logger.LogInformation(" 'agentIdentityScopes' property not found in config, using hardcoded defaults");
-
- // Use hardcoded defaults from ConfigConstants
- inheritableScopes.AddRange(ConfigConstants.DefaultAgentIdentityScopes);
-
- _logger.LogInformation(" Using {Count} default scopes: {Scopes}",
- inheritableScopes.Count, string.Join(", ", inheritableScopes));
- }
-
- if (inheritableScopes.Count > 0)
- {
- _logger.LogInformation("Total inheritable scopes configured: {Count}", inheritableScopes.Count);
- }
- else
- {
- _logger.LogWarning("No inheritable scopes available - this should not happen");
- }
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to read inheritable scopes from configuration, using defaults");
-
- // Fallback to defaults on any error
- inheritableScopes.AddRange(ConfigConstants.DefaultAgentIdentityScopes);
- _logger.LogInformation("Using {Count} default scopes as fallback", inheritableScopes.Count);
- }
-
- return inheritableScopes;
- }
-
- ///
- /// Creates and authenticates a GraphServiceClient using InteractiveGraphAuthService.
- /// This common method consolidates the authentication logic used across multiple methods.
- ///
- private async Task GetAuthenticatedGraphClientAsync(string tenantId, CancellationToken ct)
- {
- _logger.LogInformation("Authenticating to Microsoft Graph using interactive browser authentication...");
- _logger.LogInformation("IMPORTANT: Agent Blueprint operations require Application.ReadWrite.All permission.");
- _logger.LogInformation("This will open a browser window for interactive authentication.");
- _logger.LogInformation("Please sign in with a Global Administrator account.");
- _logger.LogInformation("");
-
- // Use InteractiveGraphAuthService to get proper authentication
- var interactiveAuth = new InteractiveGraphAuthService(
- LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger());
-
- try
- {
- var graphClient = await interactiveAuth.GetAuthenticatedGraphClientAsync(tenantId, ct);
- _logger.LogInformation("Successfully authenticated to Microsoft Graph");
- return graphClient;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to authenticate to Microsoft Graph: {Message}", ex.Message);
- _logger.LogError("");
- _logger.LogError("TROUBLESHOOTING:");
- _logger.LogError("1. Ensure you are a Global Administrator or have Application.ReadWrite.All permission");
- _logger.LogError("2. The account must have already consented to these permissions");
- _logger.LogError("");
- throw new InvalidOperationException($"Microsoft Graph authentication failed: {ex.Message}", ex);
- }
- }
-
- private static string Short(string? text)
- => string.IsNullOrWhiteSpace(text) ? string.Empty : (text.Length <= 180 ? text.Trim() : text[..177] + "...");
-
- private async Task GetTokenFromGraphClient(GraphServiceClient graphClient, string tenantId)
- {
- // Use Azure.Identity to get the token directly with specific scopes
- var credential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions
- {
- TenantId = tenantId,
- ClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e" // Microsoft Graph PowerShell app ID
- });
-
- var tokenRequestContext = new TokenRequestContext(new[] { "https://graph.microsoft.com/.default" });
- var token = await credential.GetTokenAsync(tokenRequestContext, CancellationToken.None);
- ValidateGraphToken(token.Token);
-
- return token.Token;
- }
-
- ///
- /// Ensures delegated consent with retry logic (3 attempts with 5-second delays)
- /// Matches the PowerShell script's retry behavior for DelegatedAgentApplicationCreateConsent.ps1
- ///
- private async Task EnsureDelegatedConsentWithRetriesAsync(
- string tenantId,
- CancellationToken cancellationToken)
- {
- const int maxRetries = 3;
- const int retryDelaySeconds = 5;
-
- for (int attempt = 1; attempt <= maxRetries; attempt++)
- {
- try
- {
- if (attempt > 1)
- {
- _logger.LogInformation("Retry attempt {Attempt} of {MaxRetries} for delegated consent", attempt, maxRetries);
- await Task.Delay(TimeSpan.FromSeconds(retryDelaySeconds), cancellationToken);
- }
-
- var success = await _delegatedConsentService.EnsureAgentApplicationCreateConsentAsync(
- MicrosoftGraphCommandLineToolsAppId,
- tenantId,
- cancellationToken);
-
- if (success)
- {
- _logger.LogInformation("Successfully ensured delegated application consent on attempt {Attempt}", attempt);
- return true;
- }
-
- _logger.LogWarning("Consent attempt {Attempt} returned false", attempt);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Consent attempt {Attempt} failed: {Message}", attempt, ex.Message);
-
- if (attempt == maxRetries)
- {
- _logger.LogError("All retry attempts exhausted for delegated consent");
- _logger.LogError("Common causes:");
- _logger.LogError(" 1. Insufficient permissions - You need Application.ReadWrite.All and DelegatedPermissionGrant.ReadWrite.All");
- _logger.LogError(" 2. Not a Global Administrator or similar privileged role");
- _logger.LogError(" 3. Azure CLI authentication expired - Run 'az login' and retry");
- _logger.LogError(" 4. Network connectivity issues");
- return false;
- }
- }
- }
-
- return false;
- }
-
- private bool CheckNeedDeployment(JsonObject setupConfig)
- {
- bool needDeployment = true; // default
- if (setupConfig.TryGetPropertyValue("needDeployment", out var needDeploymentNode) &&
- needDeploymentNode is JsonValue nv)
- {
- // Prefer native bool
- if (nv.TryGetValue(out var boolVal))
- {
- needDeployment = boolVal;
- }
- else if (nv.TryGetValue(out var strVal) && !string.IsNullOrWhiteSpace(strVal))
- {
- // Backward compatibility with "yes"/"no" / "true"/"false"
- needDeployment =
- !string.Equals(strVal, "no", StringComparison.OrdinalIgnoreCase) &&
- !string.Equals(strVal, "false", StringComparison.OrdinalIgnoreCase);
- }
- }
-
- return needDeployment;
- }
-
- ///
- /// Get the Azure Web App runtime string based on the detected platform
- ///
- private static string GetRuntimeForPlatform(Models.ProjectPlatform platform)
- {
- return platform switch
- {
- Models.ProjectPlatform.Python => "PYTHON:3.11",
- Models.ProjectPlatform.NodeJs => "NODE:20-lts",
- Models.ProjectPlatform.DotNet => "DOTNETCORE:8.0",
- _ => "DOTNETCORE:8.0" // Default fallback
- };
- }
-
- ///
- /// Get the Azure Web App Linux FX Version string based on the detected platform
- ///
- private static string GetLinuxFxVersionForPlatform(Models.ProjectPlatform platform)
- {
- return platform switch
- {
- Models.ProjectPlatform.Python => "PYTHON|3.11",
- Models.ProjectPlatform.NodeJs => "NODE|20-lts",
- Models.ProjectPlatform.DotNet => "DOTNETCORE|8.0",
- _ => "DOTNETCORE|8.0" // Default fallback
- };
- }
-}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs
index 24f43b53..7a0a5632 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs
@@ -119,12 +119,14 @@ public async Task CreateEndpointWithAgentBlueprintAsync(
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Failed to call create endpoint. Status: {Status}", response.StatusCode);
+
var errorContent = await response.Content.ReadAsStringAsync();
if (errorContent.Contains("Failed to provision bot resource via Azure Management API. Status: BadRequest", StringComparison.OrdinalIgnoreCase))
{
_logger.LogError("Please ensure that the Agent 365 CLI is supported in the selected region ('{Location}') and that your web app name ('{EndpointName}') is globally unique.", location, endpointName);
return false;
}
+
_logger.LogError("Error response: {Error}", errorContent);
return false;
}
@@ -240,8 +242,10 @@ public async Task DeleteEndpointWithAgentBlueprintAsync(
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Failed to call delete endpoint. Status: {Status}", response.StatusCode);
+
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("Error response: {Error}", errorContent);
+
return false;
}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs
index 27cd914d..b2a3efd1 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs
@@ -325,6 +325,7 @@ public async Task LoadAsync(
// Log warnings if any
if (validationResult.Warnings.Count > 0)
+ if (validationResult.Warnings.Count > 0)
{
foreach (var warning in validationResult.Warnings)
{
@@ -678,13 +679,23 @@ private string GetJsonPropertyName(PropertyInfo prop)
var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType;
if (underlyingType == typeof(string))
- return element.GetString();
+ return element.ValueKind == JsonValueKind.String
+ ? element.GetString()
+ : element.GetRawText(); // fallback: convert any other JSON type to string
if (underlyingType == typeof(int))
return element.GetInt32();
if (underlyingType == typeof(bool))
+ {
+ if (element.ValueKind == JsonValueKind.True) return true;
+ if (element.ValueKind == JsonValueKind.False) return false;
+ if (element.ValueKind == JsonValueKind.String &&
+ bool.TryParse(element.GetString(), out var result))
+ return result;
+
return element.GetBoolean();
+ }
if (underlyingType == typeof(DateTime))
return element.GetDateTime();
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/InfrastructureSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/InfrastructureSubcommandTests.cs
new file mode 100644
index 00000000..74e17503
--- /dev/null
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/InfrastructureSubcommandTests.cs
@@ -0,0 +1,192 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using FluentAssertions;
+using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands;
+using Microsoft.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Xunit;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands;
+
+public class InfrastructureSubcommandTests
+{
+ private readonly ILogger _logger;
+ private readonly CommandExecutor _commandExecutor;
+
+ public InfrastructureSubcommandTests()
+ {
+ _logger = Substitute.For();
+ _commandExecutor = Substitute.For(Substitute.For>());
+ }
+
+ [Fact]
+ public async Task EnsureAppServicePlanExists_WhenQuotaLimitExceeded_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var subscriptionId = "test-sub-id";
+ var resourceGroup = "test-rg";
+ var planName = "test-plan";
+ var planSku = "B1";
+
+ // Mock app service plan doesn't exist (initial check)
+ _commandExecutor.ExecuteAsync("az",
+ Arg.Is(s => s.Contains("appservice plan show") && s.Contains(planName)),
+ captureOutput: true,
+ suppressErrorLogging: true)
+ .Returns(new CommandResult { ExitCode = 1, StandardError = "Plan not found" });
+
+ // Mock app service plan creation fails with quota error
+ _commandExecutor.ExecuteAsync("az",
+ Arg.Is(s => s.Contains("appservice plan create") && s.Contains(planName)),
+ suppressErrorLogging: true)
+ .Returns(new CommandResult
+ {
+ ExitCode = 1,
+ StandardError = "ERROR: Operation cannot be completed without additional quota.\n\nAdditional details - Location:\n\nCurrent Limit (Basic VMs): 0\n\nCurrent Usage: 0\n\nAmount required for this deployment (Basic VMs): 1"
+ });
+
+ // Act & Assert - The method should throw because verification fails
+ var exception = await Assert.ThrowsAsync(
+ async () => await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync(_commandExecutor, _logger, resourceGroup, planName, planSku, subscriptionId));
+
+ exception.Message.Should().Contain($"Failed to create App Service plan '{planName}'");
+ }
+
+ [Fact]
+ public async Task EnsureAppServicePlanExists_WhenPlanAlreadyExists_SkipsCreation()
+ {
+ // Arrange
+ var subscriptionId = "test-sub-id";
+ var resourceGroup = "test-rg";
+ var planName = "existing-plan";
+ var planSku = "B1";
+
+ // Mock app service plan already exists
+ _commandExecutor.ExecuteAsync("az",
+ Arg.Is(s => s.Contains("appservice plan show") && s.Contains(planName)),
+ captureOutput: true,
+ suppressErrorLogging: true)
+ .Returns(new CommandResult
+ {
+ ExitCode = 0,
+ StandardOutput = "{\"name\": \"existing-plan\", \"sku\": {\"name\": \"B1\"}}"
+ });
+
+ // Act
+ await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync(_commandExecutor, _logger, resourceGroup, planName, planSku, subscriptionId);
+
+ // Assert - Verify creation command was never called
+ await _commandExecutor.DidNotReceive().ExecuteAsync("az",
+ Arg.Is(s => s.Contains("appservice plan create")),
+ suppressErrorLogging: true);
+ }
+
+ [Fact]
+ public async Task EnsureAppServicePlanExists_WhenCreationSucceeds_VerifiesExistence()
+ {
+ // Arrange
+ var subscriptionId = "test-sub-id";
+ var resourceGroup = "test-rg";
+ var planName = "new-plan";
+ var planSku = "B1";
+
+ // Mock app service plan doesn't exist initially, then exists after creation
+ var planShowCallCount = 0;
+ _commandExecutor.ExecuteAsync("az",
+ Arg.Is(s => s.Contains("appservice plan show") && s.Contains(planName)),
+ captureOutput: true,
+ suppressErrorLogging: true)
+ .Returns(callInfo =>
+ {
+ planShowCallCount++;
+ // First call: plan doesn't exist, second call (after creation): plan exists
+ return planShowCallCount == 1
+ ? new CommandResult { ExitCode = 1, StandardError = "Plan not found" }
+ : new CommandResult { ExitCode = 0, StandardOutput = "{\"name\": \"new-plan\"}" };
+ });
+
+ // Mock app service plan creation succeeds
+ _commandExecutor.ExecuteAsync("az",
+ Arg.Is(s => s.Contains("appservice plan create") && s.Contains(planName)),
+ suppressErrorLogging: true)
+ .Returns(new CommandResult { ExitCode = 0, StandardOutput = "Plan created" });
+
+ // Act
+ await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync(_commandExecutor, _logger, resourceGroup, planName, planSku, subscriptionId);
+
+ // Assert - Verify the plan creation was called
+ await _commandExecutor.Received(1).ExecuteAsync("az",
+ Arg.Is(s => s.Contains("appservice plan create") && s.Contains(planName)),
+ suppressErrorLogging: true);
+
+ // Verify the plan was checked twice (before creation and verification after)
+ await _commandExecutor.Received(2).ExecuteAsync("az",
+ Arg.Is(s => s.Contains("appservice plan show") && s.Contains(planName)),
+ captureOutput: true,
+ suppressErrorLogging: true);
+ }
+
+ [Fact]
+ public async Task EnsureAppServicePlanExists_WhenCreationFailsSilently_ThrowsInvalidOperationException()
+ {
+ // Arrange - Tests the scenario where Azure CLI returns success but the plan doesn't actually exist
+ var subscriptionId = "test-sub-id";
+ var resourceGroup = "test-rg";
+ var planName = "failed-plan";
+ var planSku = "B1";
+
+ // Mock app service plan doesn't exist before and after creation attempt
+ _commandExecutor.ExecuteAsync("az",
+ Arg.Is(s => s.Contains("appservice plan show") && s.Contains(planName)),
+ captureOutput: true,
+ suppressErrorLogging: true)
+ .Returns(new CommandResult { ExitCode = 1, StandardError = "Plan not found" });
+
+ // Mock plan creation appears to succeed but doesn't actually create the plan
+ _commandExecutor.ExecuteAsync("az",
+ Arg.Is(s => s.Contains("appservice plan create") && s.Contains(planName)),
+ suppressErrorLogging: true)
+ .Returns(new CommandResult { ExitCode = 0, StandardOutput = "" });
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(
+ async () => await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync(_commandExecutor, _logger, resourceGroup, planName, planSku, subscriptionId));
+
+ exception.Message.Should().Contain($"Failed to create App Service plan '{planName}'");
+ }
+
+ [Fact]
+ public async Task EnsureAppServicePlanExists_WhenPermissionDenied_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var subscriptionId = "test-sub-id";
+ var resourceGroup = "test-rg";
+ var planName = "test-plan";
+ var planSku = "B1";
+
+ // Mock app service plan doesn't exist
+ _commandExecutor.ExecuteAsync("az",
+ Arg.Is(s => s.Contains("appservice plan show") && s.Contains(planName)),
+ captureOutput: true,
+ suppressErrorLogging: true)
+ .Returns(new CommandResult { ExitCode = 1, StandardError = "Plan not found" });
+
+ // Mock app service plan creation fails with permission error
+ _commandExecutor.ExecuteAsync("az",
+ Arg.Is(s => s.Contains("appservice plan create") && s.Contains(planName)),
+ suppressErrorLogging: true)
+ .Returns(new CommandResult
+ {
+ ExitCode = 1,
+ StandardError = "ERROR: The client does not have authorization to perform action"
+ });
+
+ // Act & Assert - The method should throw because verification fails
+ var exception = await Assert.ThrowsAsync(
+ async () => await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync(_commandExecutor, _logger, resourceGroup, planName, planSku, subscriptionId));
+
+ exception.Message.Should().Contain($"Failed to create App Service plan '{planName}'");
+ }
+}
\ No newline at end of file
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs
index 5d410b3b..e896aa08 100644
--- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs
@@ -1,403 +1,358 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-using FluentAssertions;
-using Microsoft.Agents.A365.DevTools.Cli.Commands;
-using Microsoft.Agents.A365.DevTools.Cli.Models;
-using Microsoft.Agents.A365.DevTools.Cli.Services;
-using Microsoft.Extensions.Logging;
-using NSubstitute;
-using System.CommandLine;
-using System.CommandLine.Builder;
-using System.CommandLine.IO;
-using System.CommandLine.Parsing;
-using System.IO;
-using System.Reflection;
-using System.Threading.Tasks;
-using Xunit;
-
-namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands;
-
-///
-/// Functional tests for SetupCommand execution
-///
-public class SetupCommandTests
-{
- private readonly ILogger _mockLogger;
- private readonly IConfigService _mockConfigService;
- private readonly CommandExecutor _mockExecutor;
- private readonly DeploymentService _mockDeploymentService;
- private readonly IBotConfigurator _mockBotConfigurator;
- private readonly IAzureValidator _mockAzureValidator;
- private readonly AzureWebAppCreator _mockWebAppCreator;
- private readonly PlatformDetector _mockPlatformDetector;
- private readonly GraphApiService _mockGraphApiService;
-
- public SetupCommandTests()
- {
- _mockLogger = Substitute.For>();
- _mockConfigService = Substitute.For();
- var mockExecutorLogger = Substitute.For>();
- _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger);
- var mockDeployLogger = Substitute.For>();
- var mockPlatformDetectorLogger = Substitute.For>();
- _mockPlatformDetector = Substitute.ForPartsOf(mockPlatformDetectorLogger);
- var mockDotNetLogger = Substitute.For>();
- var mockNodeLogger = Substitute.For>();
- var mockPythonLogger = Substitute.For>();
- _mockDeploymentService = Substitute.ForPartsOf(
- mockDeployLogger,
- _mockExecutor,
- _mockPlatformDetector,
- mockDotNetLogger,
- mockNodeLogger,
- mockPythonLogger);
- _mockBotConfigurator = Substitute.For();
- _mockAzureValidator = Substitute.For();
- _mockWebAppCreator = Substitute.ForPartsOf(Substitute.For>());
- _mockGraphApiService = Substitute.For();
-
- // Prevent the real setup runner from running during tests by short-circuiting it
- SetupCommand.SetupRunnerInvoker = (setupPath, generatedPath, exec, webApp) => Task.FromResult(true);
- }
-
- [Fact]
- public async Task SetupCommand_DryRun_ValidConfig_OnlyValidatesConfig()
- {
- // Arrange
- var config = new Agent365Config { TenantId = "tenant", SubscriptionId = "sub", ResourceGroup = "rg", Location = "loc", AppServicePlanName = "plan", WebAppName = "web", AgentIdentityDisplayName = "agent", DeploymentProjectPath = "." };
- _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config));
- var command = SetupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockExecutor, _mockDeploymentService, _mockBotConfigurator, _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, _mockGraphApiService);
- var parser = new CommandLineBuilder(command).Build();
- var testConsole = new TestConsole();
-
- // Act
- var result = await parser.InvokeAsync("--dry-run", testConsole);
-
- // Assert
- Assert.Equal(0, result);
-
- // Dry-run should load config but must not call Azure/Bot services
- await _mockConfigService.Received(1).LoadAsync(Arg.Any(), Arg.Any());
- await _mockAzureValidator.DidNotReceiveWithAnyArgs().ValidateAllAsync(default!);
- await _mockBotConfigurator.DidNotReceiveWithAnyArgs().CreateEndpointWithAgentBlueprintAsync(default!, default!, default!, default!, default!);
- }
-
- [Fact]
- public async Task SetupCommand_McpPermissionFailure_DoesNotThrowUnhandledException()
- {
- // Arrange
- var config = new Agent365Config
- {
- TenantId = "tenant",
- SubscriptionId = "sub",
- ResourceGroup = "rg",
- Location = "eastus",
- AppServicePlanName = "plan",
- WebAppName = "web",
- AgentIdentityDisplayName = "agent",
- DeploymentProjectPath = ".",
- AgentBlueprintId = "blueprint-app-id",
- Environment = "prod"
- };
-
- _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config));
- _mockAzureValidator.ValidateAllAsync(Arg.Any()).Returns(Task.FromResult(true));
-
- // Simulate MCP permission failure by setting up a failing mock
- SetupCommand.SetupRunnerInvoker = async (setupPath, generatedPath, exec, webApp) =>
- {
- // Simulate blueprint creation success but write minimal generated config
- var generatedConfig = new
- {
- agentBlueprintId = "test-blueprint-id",
- agentBlueprintObjectId = "test-object-id",
- tenantId = "tenant"
- };
-
- await File.WriteAllTextAsync(generatedPath, System.Text.Json.JsonSerializer.Serialize(generatedConfig));
- return true;
- };
-
- var command = SetupCommand.CreateCommand(
- _mockLogger,
- _mockConfigService,
- _mockExecutor,
- _mockDeploymentService,
- _mockBotConfigurator,
- _mockAzureValidator,
- _mockWebAppCreator,
- _mockPlatformDetector,
- _mockGraphApiService);
-
- var parser = new CommandLineBuilder(command).Build();
- var testConsole = new TestConsole();
-
- // Act - Even if MCP permissions fail, setup should not throw unhandled exception
- var result = await parser.InvokeAsync("setup", testConsole);
-
- // Assert - The command should complete without unhandled exceptions
- // It may log errors but should not crash
- result.Should().BeOneOf(0, 1); // May return 0 (success) or 1 (partial failure) but should not throw
- }
-
- [Fact]
- public void SetupCommand_ErrorMessages_ShouldBeInformativeAndActionable()
- {
- // Arrange
- var mockLogger = Substitute.For>();
-
- // Act - Verify that error messages are being logged with sufficient detail
- // This is a placeholder for ensuring error messages follow best practices
-
- // Assert - Error messages should:
- // 1. Explain what failed
- mockLogger.ReceivedCalls().Should().NotBeNull();
-
- // 2. Provide context (e.g., which resource, which permission)
- // 3. Suggest remediation steps
- // 4. Not contain emojis or special characters
- }
-
- [Fact]
- public async Task SetupCommand_BlueprintCreationSuccess_LogsAtInfoLevel()
- {
- // Arrange
- var config = new Agent365Config
- {
- TenantId = "tenant",
- SubscriptionId = "sub",
- ResourceGroup = "rg",
- Location = "eastus",
- AppServicePlanName = "plan",
- WebAppName = "web",
- AgentIdentityDisplayName = "agent",
- DeploymentProjectPath = ".",
- AgentBlueprintId = "blueprint-app-id"
- };
-
- _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config));
- _mockAzureValidator.ValidateAllAsync(Arg.Any()).Returns(Task.FromResult(true));
-
- SetupCommand.SetupRunnerInvoker = async (setupPath, generatedPath, exec, webApp) =>
- {
- var generatedConfig = new
- {
- agentBlueprintId = "test-blueprint-id",
- agentBlueprintObjectId = "test-object-id",
- tenantId = "tenant",
- completed = true
- };
-
- await File.WriteAllTextAsync(generatedPath, System.Text.Json.JsonSerializer.Serialize(generatedConfig));
- return true;
- };
-
- var command = SetupCommand.CreateCommand(
- _mockLogger,
- _mockConfigService,
- _mockExecutor,
- _mockDeploymentService,
- _mockBotConfigurator,
- _mockAzureValidator,
- _mockWebAppCreator,
- _mockPlatformDetector,
- _mockGraphApiService);
-
- var parser = new CommandLineBuilder(command).Build();
- var testConsole = new TestConsole();
-
- // Act
- var result = await parser.InvokeAsync("setup", testConsole);
-
- // Assert - Blueprint creation success should be logged at Info level
- _mockLogger.ReceivedCalls().Should().NotBeEmpty();
- }
-
- [Fact]
- public async Task SetupCommand_GeneratedConfigPath_LoggedAtDebugLevel()
- {
- // Arrange
- var config = new Agent365Config
- {
- TenantId = "tenant",
- SubscriptionId = "sub",
- ResourceGroup = "rg",
- Location = "eastus",
- AppServicePlanName = "plan",
- WebAppName = "web",
- AgentIdentityDisplayName = "agent",
- DeploymentProjectPath = ".",
- AgentBlueprintId = "blueprint-app-id"
- };
-
- _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config));
- _mockAzureValidator.ValidateAllAsync(Arg.Any()).Returns(Task.FromResult(true));
-
- SetupCommand.SetupRunnerInvoker = async (setupPath, generatedPath, exec, webApp) =>
- {
- var generatedConfig = new
- {
- agentBlueprintId = "test-blueprint-id"
- };
-
- await File.WriteAllTextAsync(generatedPath, System.Text.Json.JsonSerializer.Serialize(generatedConfig));
- return true;
- };
-
- var command = SetupCommand.CreateCommand(
- _mockLogger,
- _mockConfigService,
- _mockExecutor,
- _mockDeploymentService,
- _mockBotConfigurator,
- _mockAzureValidator,
- _mockWebAppCreator,
- _mockPlatformDetector,
- _mockGraphApiService);
-
- var parser = new CommandLineBuilder(command).Build();
- var testConsole = new TestConsole();
-
- // Act
- await parser.InvokeAsync("setup", testConsole);
-
- // Assert - Generated config path should be logged at Debug level, not Info
- // This test verifies that implementation detail messages are not shown to users by default
- _mockLogger.Received().Log(
- LogLevel.Debug,
- Arg.Any(),
- Arg.Any