diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 5170e6b1..8d5e9712 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -30,7 +30,8 @@ public static Command CreateCommand( IBotConfigurator botConfigurator, IAzureValidator azureValidator, AzureWebAppCreator webAppCreator, - PlatformDetector platformDetector) + 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"); @@ -85,11 +86,17 @@ public static Command CreateCommand( { // Load configuration - ConfigService automatically finds generated config in same directory var setupConfig = await configService.LoadAsync(config.FullName); - - // Validate Azure CLI authentication, subscription, and environment - if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) + if (setupConfig.NeedDeployment) { - Environment.Exit(1); + // 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(""); @@ -104,19 +111,14 @@ public static Command CreateCommand( bool success; - // Use C# runner with GraphApiService - var graphService = new GraphApiService( - LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(), - executor); - var delegatedConsentService = new DelegatedConsentService( LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(), - graphService); + graphApiService); var setupRunner = new A365SetupRunner( LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(), executor, - graphService, + graphApiService, webAppCreator, delegatedConsentService, platformDetector); @@ -145,7 +147,7 @@ public static Command CreateCommand( logger.LogError("Agent blueprint creation failed"); setupResults.BlueprintCreated = false; setupResults.Errors.Add("Agent blueprint creation failed"); - throw new InvalidOperationException("Setup runner execution failed"); + throw new SetupValidationException("Setup runner execution failed"); } setupResults.BlueprintCreated = true; @@ -174,7 +176,7 @@ public static Command CreateCommand( try { await EnsureMcpOauth2PermissionGrantsAsync( - graphService, + graphApiService, setupConfig, toolingScopes, logger @@ -182,7 +184,7 @@ await EnsureMcpOauth2PermissionGrantsAsync( // Apply inheritable permissions on the agent identity blueprint await EnsureMcpInheritablePermissionsAsync( - graphService, + graphApiService, setupConfig, toolingScopes, logger @@ -222,20 +224,20 @@ await EnsureMcpInheritablePermissionsAsync( logger.LogInformation(""); if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) - throw new InvalidOperationException("AgentBlueprintId is required."); + throw new SetupValidationException("AgentBlueprintId is required."); - var blueprintSpObjectId = await graphService.LookupServicePrincipalByAppIdAsync(setupConfig.TenantId, setupConfig.AgentBlueprintId) - ?? throw new InvalidOperationException($"Blueprint Service Principal not found for appId {setupConfig.AgentBlueprintId}"); + 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 graphService.EnsureServicePrincipalForAppIdAsync( + var botApiResourceSpObjectId = await graphApiService.EnsureServicePrincipalForAppIdAsync( setupConfig.TenantId, ConfigConstants.MessagingBotApiAppId); try { // Grant oauth2PermissionGrants: blueprint SP -> Messaging Bot API SP - var botApiGrantOk = await graphService.CreateOrUpdateOauth2PermissionGrantAsync( + var botApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( setupConfig.TenantId, blueprintSpObjectId, botApiResourceSpObjectId, @@ -248,7 +250,7 @@ await EnsureMcpInheritablePermissionsAsync( } // Add inheritable permissions on blueprint for Messaging Bot API - var (ok, already, err) = await graphService.SetInheritablePermissionsAsync( + var (ok, already, err) = await graphApiService.SetInheritablePermissionsAsync( setupConfig.TenantId, setupConfig.AgentBlueprintId, ConfigConstants.MessagingBotApiAppId, @@ -405,27 +407,100 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( if (string.IsNullOrEmpty(setupConfig.AgentBlueprintId)) { logger.LogError("Agent Blueprint ID not found. Blueprint creation may have failed."); - throw new InvalidOperationException("Agent Blueprint ID is required for messaging endpoint registration"); + 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 ?? "" + }); } - if (string.IsNullOrEmpty(setupConfig.WebAppName)) + 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 { - logger.LogError("Web App Name not configured in a365.config.json"); - throw new InvalidOperationException("Web App Name is required for messaging endpoint registration"); + // 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); } - // Generate endpoint name with Azure Bot Service constraints (4-42 chars) - var baseEndpointName = $"{setupConfig.WebAppName}-endpoint"; - var 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 InvalidOperationException($"Bot endpoint name '{endpointName}' is too short (must be at least 4 characters)"); + throw new SetupValidationException($"Bot endpoint name '{endpointName}' is too short (must be at least 4 characters)"); } - // Register messaging endpoint using agent blueprint identity and deployed web app URL - var messagingEndpoint = $"https://{setupConfig.WebAppName}.azurewebsites.net/api/messages"; - logger.LogInformation(" - Registering blueprint messaging endpoint"); logger.LogInformation(" * Endpoint Name: {EndpointName}", endpointName); logger.LogInformation(" * Messaging Endpoint: {Endpoint}", messagingEndpoint); @@ -441,78 +516,84 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( if (!endpointRegistered) { logger.LogError("Failed to register blueprint messaging endpoint"); - throw new InvalidOperationException("Blueprint messaging endpoint registration failed"); + throw new SetupValidationException("Blueprint messaging endpoint registration failed"); } } + /// + /// Ensure OAuth2 permission grants are set from blueprint to MCP server + /// private static async Task EnsureMcpOauth2PermissionGrantsAsync( GraphApiService graph, - Agent365Config cfg, + Agent365Config config, string[] scopes, ILogger logger, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(cfg.AgentBlueprintId)) - throw new InvalidOperationException("AgentBlueprintId (appId) is required."); + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) + throw new SetupValidationException("AgentBlueprintId (appId) is required."); - var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(cfg.TenantId, cfg.AgentBlueprintId, ct); + var blueprintSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, config.AgentBlueprintId, ct); if (string.IsNullOrWhiteSpace(blueprintSpObjectId)) { - throw new InvalidOperationException($"Blueprint Service Principal not found for appId {cfg.AgentBlueprintId}. " + + 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(cfg.Environment); - var Agent365ToolsSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(cfg.TenantId, resourceAppId, ct); + var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var Agent365ToolsSpObjectId = await graph.LookupServicePrincipalByAppIdAsync(config.TenantId, resourceAppId, ct); if (string.IsNullOrWhiteSpace(Agent365ToolsSpObjectId)) { - throw new InvalidOperationException($"Agent 365 Tools Service Principal not found for appId {resourceAppId}. " + - $"Ensure the Agent 365 Tools application is available in your tenant for environment: {cfg.Environment}"); + 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( - cfg.TenantId, blueprintSpObjectId, Agent365ToolsSpObjectId, scopes, ct); + config.TenantId, blueprintSpObjectId, Agent365ToolsSpObjectId, scopes, ct); if (!response) { - throw new InvalidOperationException( - $"Failed to create/update OAuth2 permission grant from blueprint {cfg.AgentBlueprintId} to Agent 365 Tools {resourceAppId}. " + + 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 inheritable permissions are set from blueprint to MCP server + /// private static async Task EnsureMcpInheritablePermissionsAsync( GraphApiService graph, - Agent365Config cfg, + Agent365Config config, string[] scopes, ILogger logger, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(cfg.AgentBlueprintId)) - throw new InvalidOperationException("AgentBlueprintId (appId) is required."); + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) + throw new SetupValidationException("AgentBlueprintId (appId) is required."); - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(cfg.Environment); + var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); logger.LogInformation(" - Inheritable permissions: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]", - cfg.AgentBlueprintId, resourceAppId, string.Join(' ', scopes)); + config.AgentBlueprintId, resourceAppId, string.Join(' ', scopes)); var (ok, alreadyExists, err) = await graph.SetInheritablePermissionsAsync( - cfg.TenantId, cfg.AgentBlueprintId, resourceAppId, scopes, new List() { "AgentIdentityBlueprint.ReadWrite.All" }, ct); + config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, new List() { "AgentIdentityBlueprint.ReadWrite.All" }, ct); if (!ok && !alreadyExists) { - cfg.InheritanceConfigured = false; - cfg.InheritanceConfigError = err; - throw new InvalidOperationException($"Failed to set inheritable permissions: {err}. " + + 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."); } - cfg.InheritanceConfigured = true; - cfg.InheritablePermissionsAlreadyExist = alreadyExists; - cfg.InheritanceConfigError = null; + config.InheritanceConfigured = true; + config.InheritablePermissionsAlreadyExist = alreadyExists; + config.InheritanceConfigError = null; } /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs index 12474a29..5625538a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs @@ -10,5 +10,7 @@ public static class ErrorCodes public const string DeploymentAppCompileFailed = "DEPLOYMENT_APP_COMPILE_FAILED"; public const string DeploymentScopesFailed = "DEPLOYMENT_SCOPES_FAILED"; public const string DeploymentMcpFailed = "DEPLOYMENT_MCP_FAILED"; + public const string HighPrivilegeScopeDetected = "HIGH_PRIVILEGE_SCOPE_DETECTED"; + public const string SetupValidationFailed = "SETUP_VALIDATION_FAILED"; } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/Agent365Exception.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/Agent365Exception.cs index 28e8ccbc..15ff1e8b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/Agent365Exception.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/Agent365Exception.cs @@ -1,6 +1,6 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + using System.Text; namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; @@ -80,7 +80,7 @@ private static string BuildMessage(string errorCode, string issueDescription, Li sb.AppendLine(); foreach (var detail in errorDetails) { - sb.AppendLine($" � {detail}"); + sb.AppendLine($" * {detail}"); } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/GraphTokenScopeException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/GraphTokenScopeException.cs new file mode 100644 index 00000000..2d581761 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/GraphTokenScopeException.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; + +namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; + +/// +/// Exception thrown when a Graph access token contains disallowed high-privilege scopes. +/// +public class GraphTokenScopeException : Agent365Exception +{ + private const string IssueDescriptionText = "Graph token contains high-privilege scopes"; + + public GraphTokenScopeException(string scope) + : base( + errorCode: ErrorCodes.HighPrivilegeScopeDetected, + issueDescription: IssueDescriptionText, + errorDetails: new List { $"Disallowed scope detected in token: {scope}" }, + mitigationSteps: new List + { + "Check Microsoft Graph Command Line Tools app permissions: Azure portal ? App registrations ? 'Microsoft Graph Command Line Tools' (App ID: 14d82eec-204b-4c2f-b7e8-296a70dab67e) ? API permissions.", + "Look for 'Directory.AccessAsUser.All' and remove it or replace it with a least-privilege alternative (for example 'Directory.Read.All') if appropriate.", + "Re-run the CLI and, when the browser consent prompt appears, approve only the scopes requested by the CLI.", + "Note: Removing tenant-wide admin consent for this permission may impact other tools or automation that rely on it. Verify impact before removal." + }) + { + } + + public override int ExitCode => 2; // Configuration / permission error +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/SetupValidationException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/SetupValidationException.cs new file mode 100644 index 00000000..63a414a0 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/SetupValidationException.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; + +namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; + +/// +/// Validation errors that occur during `a365 setup` (user-fixable issues). +/// +public sealed class SetupValidationException : Agent365Exception +{ + public override int ExitCode => 2; + + public SetupValidationException( + string issueDescription, + List? errorDetails = null, + List? mitigationSteps = null, + Dictionary? context = null, + Exception? innerException = null) + : base( + errorCode: ErrorCodes.SetupValidationFailed, + issueDescription: issueDescription, + errorDetails: errorDetails, + mitigationSteps: mitigationSteps, + context: context, + innerException: innerException) + { + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index ea4e4680..6b5e5ab5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -24,14 +24,27 @@ public class Agent365Config public List Validate() { var errors = new List(); + if (string.IsNullOrWhiteSpace(TenantId)) errors.Add("tenantId is required."); - if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); - if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); - if (string.IsNullOrWhiteSpace(Location)) errors.Add("location is required."); - if (string.IsNullOrWhiteSpace(AppServicePlanName)) errors.Add("appServicePlanName is required."); - if (string.IsNullOrWhiteSpace(WebAppName)) errors.Add("webAppName is required."); + + if (NeedDeployment) + { + if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); + if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); + if (string.IsNullOrWhiteSpace(Location)) errors.Add("location is required."); + if (string.IsNullOrWhiteSpace(AppServicePlanName)) errors.Add("appServicePlanName is required."); + if (string.IsNullOrWhiteSpace(WebAppName)) errors.Add("webAppName is required."); + } + else + { + // Non-Azure hosting + if (string.IsNullOrWhiteSpace(MessagingEndpoint)) + errors.Add("messagingEndpoint is required when needDeployment is 'no'."); + } + if (string.IsNullOrWhiteSpace(AgentIdentityDisplayName)) errors.Add("agentIdentityDisplayName is required."); if (string.IsNullOrWhiteSpace(DeploymentProjectPath)) errors.Add("deploymentProjectPath is required."); + // agentIdentityScopes and agentApplicationScopes are now hardcoded defaults // botName and botDisplayName are now derived, not required in config // Add more validation as needed (e.g., GUID format, allowed values, etc.) @@ -76,6 +89,22 @@ public List Validate() [JsonPropertyName("environment")] public string Environment { get; init; } = "preprod"; + /// + /// For External hosting, this is the HTTPS messaging endpoint that Bot Framework will call. + /// For AzureAppService, this is optional; the CLI derives the endpoint from webAppName. + /// + [JsonPropertyName("messagingEndpoint")] + public string? MessagingEndpoint { get; init; } + + /// + /// Whether the CLI should create and deploy an Azure Web App for this agent. + /// Backed by the 'needDeployment' config value: + /// - true (default) => CLI provisions App Service + MSI, a365 deploy app is active. + /// - false => CLI does NOT create a web app; a365 deploy app is a no-op and MessagingEndpoint must be provided. + /// + [JsonPropertyName("needDeployment")] + public bool NeedDeployment { get; init; } = true; + #endregion #region App Service Configuration @@ -163,13 +192,32 @@ public List Validate() // BotName and BotDisplayName are now derived properties /// - /// Gets the internal name for the endpoint registration, derived from WebAppName. + /// Gets the internal name for the endpoint registration. + /// - For AzureAppService, derived from WebAppName. + /// - For non-Azure hosting, derived from MessagingEndpoint host if possible. /// [JsonIgnore] - public string BotName => string.IsNullOrWhiteSpace(WebAppName) ? string.Empty : $"{WebAppName}-endpoint"; + public string BotName + { + get + { + if (!string.IsNullOrWhiteSpace(WebAppName)) + { + return $"{WebAppName}-endpoint"; + } + + if (!string.IsNullOrWhiteSpace(MessagingEndpoint) && + Uri.TryCreate(MessagingEndpoint, UriKind.Absolute, out var uri)) + { + return $"{uri.Host.Replace('.', '-')}-endpoint"; + } + + return string.Empty; + } + } /// - /// Gets the display name for the bot, derived from AgentBlueprintDisplayName. + /// Gets the display name for the bot, derived from AgentBlueprintDisplayName or WebAppName. /// [JsonIgnore] public string BotDisplayName => !string.IsNullOrWhiteSpace(AgentBlueprintDisplayName) ? AgentBlueprintDisplayName! : WebAppName; @@ -530,4 +578,4 @@ public class AtgConfiguration public List McpServers { get; set; } = new(); public List ToolsServers { get; set; } = new(); public string Agent365ToolsEndpoint { get; set; } = string.Empty; -} +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 902be815..61e3a99f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -88,7 +88,7 @@ static async Task Main(string[] args) rootCommand.AddCommand(DevelopCommand.CreateCommand(developLogger, configService, executor, authService)); rootCommand.AddCommand(DevelopMcpCommand.CreateCommand(developLogger, toolingService)); rootCommand.AddCommand(SetupCommand.CreateCommand(setupLogger, configService, executor, - deploymentService, botConfigurator, azureValidator, webAppCreator, platformDetector)); + deploymentService, botConfigurator, azureValidator, webAppCreator, platformDetector, graphApiService)); rootCommand.AddCommand(CreateInstanceCommand.CreateCommand(createInstanceLogger, configService, executor, botConfigurator, graphApiService, azureValidator)); rootCommand.AddCommand(DeployCommand.CreateCommand(deployLogger, configService, executor, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index cc8e6c57..b1134575 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -1,18 +1,21 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json; -using System.Text.Json.Nodes; -using Microsoft.Extensions.Logging; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Runtime.InteropServices; -using Microsoft.Graph; -using Azure.Identity; using Azure.Core; +using Azure.Identity; +using System.Linq; 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 Microsoft.Graph.Models; +using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; +using static System.Formats.Asn1.AsnWriter; namespace Microsoft.Agents.A365.DevTools.Cli.Services; @@ -50,6 +53,50 @@ public A365SetupRunner( _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. @@ -89,11 +136,30 @@ public async Task RunAsync(string configPath, string generatedConfigPath, if (string.IsNullOrWhiteSpace(planSku)) planSku = "B1"; var deploymentProjectPath = Get("deploymentProjectPath"); + + bool needDeployment = CheckNeedDeployment(cfg); - if (new[] { subscriptionId, tenantId, resourceGroup, planName, webAppName, location }.Any(string.IsNullOrWhiteSpace)) + var skipInfra = blueprintOnly || !needDeployment; + if (!skipInfra) { - _logger.LogError("Config missing required properties. Need subscriptionId, tenantId, resourceGroup, appServicePlanName, webAppName, location."); - return false; + // 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 @@ -119,86 +185,93 @@ public async Task RunAsync(string configPath, string generatedConfigPath, // ======================================================================== // Phase 0: Ensure Azure CLI is logged in with proper scope // ======================================================================== - _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) + if (!skipInfra) { - _logger.LogInformation("Azure CLI not authenticated. Initiating login with management scope..."); - _logger.LogInformation("A browser window will open for authentication."); + _logger.LogInformation("==> [0/5] Verifying Azure CLI authentication"); - // Use standard login without scope parameter (more reliable) - var loginResult = await _executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken); - - if (!loginResult.Success) + // Check if logged in + var accountCheck = await _executor.ExecuteAsync("az", "account show", captureOutput: true, suppressErrorLogging: true); + if (!accountCheck.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 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); } - - _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) + else { - _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 already authenticated"); } - _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( + // 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 (!retryTokenCheck.Success) + if (!tokenCheck.Success) { - _logger.LogWarning("Still unable to acquire management scope token after re-authentication."); - _logger.LogWarning("Continuing anyway - you may encounter permission errors later."); + _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 token acquired successfully!"); + _logger.LogInformation("Management scope verified successfully"); } + + _logger.LogInformation(""); } else { - _logger.LogInformation("Management scope verified successfully"); + _logger.LogInformation("==> [0/5] Skipping Azure management authentication (blueprint-only or External hosting)"); } - - _logger.LogInformation(""); // ======================================================================== // Phase 1: Deploy Agent runtime (App Service) + System-assigned Managed Identity @@ -206,9 +279,14 @@ public async Task RunAsync(string configPath, string generatedConfigPath, string? principalId = null; JsonObject generatedConfig = new JsonObject(); - if (blueprintOnly) + if (skipInfra) { - _logger.LogInformation("==> [1/5] Skipping Azure infrastructure (--blueprint mode)"); + // 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 @@ -451,7 +529,7 @@ public async Task RunAsync(string configPath, string generatedConfigPath, await CreateBlueprintClientSecretAsync(blueprintObjectId!, blueprintAppId!, generatedConfig, generatedConfigPath, cancellationToken); } - catch (Exception ex) + catch (Exception ex) when (ex is not Agent365Exception) { _logger.LogError(ex, "Failed to create agent blueprint: {Message}", ex.Message); return false; @@ -828,7 +906,7 @@ public async Task RunAsync(string configPath, string generatedConfigPath, return (true, appId, objectId, servicePrincipalId); } - catch (Exception ex) + catch (Exception ex) when (ex is not Agent365Exception) { _logger.LogError(ex, "Failed to create agent blueprint: {Message}", ex.Message); return (false, null, null, null); @@ -1227,8 +1305,6 @@ private async Task AzWarnAsync(string args, string description) } } - // PollAdminConsentAsync logic moved to AdminConsentHelper to enable reuse. - private void TryOpenBrowser(string url) { try @@ -1603,34 +1679,22 @@ private async Task GetAuthenticatedGraphClientAsync(string t private static string Short(string? text) => string.IsNullOrWhiteSpace(text) ? string.Empty : (text.Length <= 180 ? text.Trim() : text[..177] + "..."); - /// - /// Extracts the access token from a GraphServiceClient for use in direct HTTP calls. - /// This uses InteractiveBrowserCredential directly which is simpler and more reliable. - /// private async Task GetTokenFromGraphClient(GraphServiceClient graphClient, string tenantId) { - try + // Use Azure.Identity to get the token directly with specific scopes + var credential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions { - // 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; - } + 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 @@ -1684,6 +1748,29 @@ private async Task EnsureDelegatedConsentWithRetriesAsync( 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 diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs index d0d524b0..27cd914d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs @@ -312,7 +312,7 @@ public async Task LoadAsync( _logger?.LogError("Configuration validation failed:"); foreach (var error in validationResult.Errors) { - _logger?.LogError(" � {Error}", error); + _logger?.LogError(" * {Error}", error); } // Convert validation errors to structured exception @@ -328,7 +328,7 @@ public async Task LoadAsync( { foreach (var warning in validationResult.Warnings) { - _logger?.LogWarning(" � {Warning}", warning); + _logger?.LogWarning(" * {Warning}", warning); } } @@ -377,20 +377,32 @@ public async Task ValidateAsync(Agent365Config config) var errors = new List(); var warnings = new List(); - // Validate required static properties ValidateRequired(config.TenantId, nameof(config.TenantId), errors); - ValidateRequired(config.SubscriptionId, nameof(config.SubscriptionId), errors); - ValidateRequired(config.ResourceGroup, nameof(config.ResourceGroup), errors); - ValidateRequired(config.Location, nameof(config.Location), errors); - - // Validate GUID formats ValidateGuid(config.TenantId, nameof(config.TenantId), errors); - ValidateGuid(config.SubscriptionId, nameof(config.SubscriptionId), errors); - // Validate Azure naming conventions - ValidateResourceGroupName(config.ResourceGroup, errors); - ValidateAppServicePlanName(config.AppServicePlanName, errors); - ValidateWebAppName(config.WebAppName, errors); + if (config.NeedDeployment) + { + // Validate required static properties + ValidateRequired(config.SubscriptionId, nameof(config.SubscriptionId), errors); + ValidateRequired(config.ResourceGroup, nameof(config.ResourceGroup), errors); + ValidateRequired(config.Location, nameof(config.Location), errors); + ValidateRequired(config.AppServicePlanName, nameof(config.AppServicePlanName), errors); + ValidateRequired(config.WebAppName, nameof(config.WebAppName), errors); + + // Validate GUID formats + ValidateGuid(config.SubscriptionId, nameof(config.SubscriptionId), errors); + + // Validate Azure naming conventions + ValidateResourceGroupName(config.ResourceGroup, errors); + ValidateAppServicePlanName(config.AppServicePlanName, errors); + ValidateWebAppName(config.WebAppName, errors); + } + else + { + // Only validate bot messaging endpoint + ValidateRequired(config.MessagingEndpoint, nameof(config.MessagingEndpoint), errors); + ValidateUrl(config.MessagingEndpoint, nameof(config.MessagingEndpoint), errors); + } // Validate dynamic properties if they exist if (config.ManagedIdentityPrincipalId != null) 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 6585152b..6b845d1d 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 @@ -30,6 +30,7 @@ public class SetupCommandTests private readonly IAzureValidator _mockAzureValidator; private readonly AzureWebAppCreator _mockWebAppCreator; private readonly PlatformDetector _mockPlatformDetector; + private readonly GraphApiService _mockGraphApiService; public SetupCommandTests() { @@ -53,6 +54,7 @@ public SetupCommandTests() _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); @@ -64,7 +66,7 @@ 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); + var command = SetupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockExecutor, _mockDeploymentService, _mockBotConfigurator, _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, _mockGraphApiService); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -124,7 +126,8 @@ public async Task SetupCommand_McpPermissionFailure_DoesNotThrowUnhandledExcepti _mockBotConfigurator, _mockAzureValidator, _mockWebAppCreator, - _mockPlatformDetector); + _mockPlatformDetector, + _mockGraphApiService); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -197,7 +200,8 @@ public async Task SetupCommand_BlueprintCreationSuccess_LogsAtInfoLevel() _mockBotConfigurator, _mockAzureValidator, _mockWebAppCreator, - _mockPlatformDetector); + _mockPlatformDetector, + _mockGraphApiService); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -248,7 +252,8 @@ public async Task SetupCommand_GeneratedConfigPath_LoggedAtDebugLevel() _mockBotConfigurator, _mockAzureValidator, _mockWebAppCreator, - _mockPlatformDetector); + _mockPlatformDetector, + _mockGraphApiService); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -308,7 +313,8 @@ public async Task SetupCommand_PartialFailure_DisplaysComprehensiveSummary() _mockBotConfigurator, _mockAzureValidator, _mockWebAppCreator, - _mockPlatformDetector); + _mockPlatformDetector, + _mockGraphApiService); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -371,7 +377,8 @@ public async Task SetupCommand_AllStepsSucceed_ShowsSuccessfulSummary() _mockBotConfigurator, _mockAzureValidator, _mockWebAppCreator, - _mockPlatformDetector); + _mockPlatformDetector, + _mockGraphApiService); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole();