Skip to content
Merged
171 changes: 128 additions & 43 deletions src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,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("");
Expand Down Expand Up @@ -141,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;
Expand Down Expand Up @@ -218,10 +224,10 @@ await EnsureMcpInheritablePermissionsAsync(
logger.LogInformation("");

if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId))
throw new InvalidOperationException("AgentBlueprintId is required.");
throw new SetupValidationException("AgentBlueprintId is required.");

var blueprintSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync(setupConfig.TenantId, setupConfig.AgentBlueprintId)
?? throw new InvalidOperationException($"Blueprint Service Principal not found for appId {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(
Expand Down Expand Up @@ -401,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<string>
{
"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<string>
{
"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<string, string>
{
["AgentBlueprintId"] = setupConfig.AgentBlueprintId ?? "<null>"
});
}

if (string.IsNullOrEmpty(setupConfig.WebAppName))
string messagingEndpoint;
string endpointName;
if (setupConfig.NeedDeployment)
{
logger.LogError("Web App Name not configured in a365.config.json");
throw new InvalidOperationException("Web App Name is required for messaging endpoint registration");
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<string>
{
"NeedDeployment is true, but 'webAppName' was not provided in a365.config.json."
},
mitigationSteps: new List<string>
{
"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<string, string>
{
["needDeployment"] = setupConfig.NeedDeployment.ToString(),
["webAppName"] = setupConfig.WebAppName ?? "<null>"
});
}

// 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<string>
{
"needDeployment is set to 'no', but MessagingEndpoint was not provided in a365.config.json."
},
mitigationSteps: new List<string>
{
"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);
Expand All @@ -437,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");
}
}

/// <summary>
/// Ensure OAuth2 permission grants are set from blueprint to MCP server
/// </summary>
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.");
}
}

/// <summary>
/// Ensure inheritable permissions are set from blueprint to MCP server
/// </summary>
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<string>() { "AgentIdentityBlueprint.ReadWrite.All" }, ct);
config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, new List<string>() { "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;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ public static class ErrorCodes
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";
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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}");
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Validation errors that occur during `a365 setup` (user-fixable issues).
/// </summary>
public sealed class SetupValidationException : Agent365Exception
{
public override int ExitCode => 2;

public SetupValidationException(
string issueDescription,
List<string>? errorDetails = null,
List<string>? mitigationSteps = null,
Dictionary<string, string>? context = null,
Exception? innerException = null)
: base(
errorCode: ErrorCodes.SetupValidationFailed,
issueDescription: issueDescription,
errorDetails: errorDetails,
mitigationSteps: mitigationSteps,
context: context,
innerException: innerException)
{
}
}
Loading