diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs index 4d16b3f1..d8bc4dc1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs @@ -398,7 +398,7 @@ public static Command CreateCommand( using (var content = new StringContent(titlePayload, System.Text.Encoding.UTF8, "application/json")) { titlesResp = await http.PostAsync(titlesUrl, content); - } + } } catch (HttpRequestException ex) { @@ -439,8 +439,8 @@ public static Command CreateCommand( HttpResponseMessage allowResp; try { - using var content = new StringContent(allowedPayload, System.Text.Encoding.UTF8, "application/json"); - allowResp = await http.PostAsync(allowUrl, content); + using var content = new StringContent(allowedPayload, System.Text.Encoding.UTF8, "application/json"); + allowResp = await http.PostAsync(allowUrl, content); } catch (HttpRequestException ex) { @@ -478,7 +478,6 @@ public static Command CreateCommand( return; } - // Use native C# service for Graph operations logger.LogInformation("Executing Graph API operations (native C# implementation)..."); logger.LogInformation("TenantId: {TenantId}, BlueprintId: {BlueprintId}", tenantId, blueprintId); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 5739c9f8..aabc5b43 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -324,25 +324,6 @@ await ProjectSettingsSyncHelper.ExecuteAsync( return command; } - /// - /// Convert Agent365Config to DeploymentConfiguration - /// - private static DeploymentConfiguration ConvertToDeploymentConfig(Agent365Config config) - { - return new DeploymentConfiguration - { - ResourceGroup = config.ResourceGroup, - AppName = config.WebAppName, - ProjectPath = config.DeploymentProjectPath, - DeploymentZip = "app.zip", - BuildConfiguration = "Release", - PublishOptions = new PublishOptions - { - SelfContained = false, - OutputPath = "publish" - } - }; - } /// /// Display verification URLs and next steps after successful setup @@ -439,8 +420,18 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( throw new InvalidOperationException("Messaging endpoint provider registration failed"); } + // Generate endpoint name with Azure Bot Service constraints (4-42 chars) + var baseEndpointName = $"{setupConfig.WebAppName}-endpoint"; + var endpointName = baseEndpointName.Length > 42 + ? baseEndpointName.Substring(0, 42) + : 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)"); + } + // Register messaging endpoint using agent blueprint identity and deployed web app URL - var endpointName = $"{setupConfig.WebAppName}-endpoint"; var messagingEndpoint = $"https://{setupConfig.WebAppName}.azurewebsites.net/api/messages"; logger.LogInformation(" - Registering blueprint messaging endpoint"); @@ -484,21 +475,6 @@ private static async Task RegisterBlueprintMessagingEndpointAsync( } } - /// - /// Get well-known resource names for common Microsoft services - /// - private static string GetWellKnownResourceName(string? resourceAppId) - { - return resourceAppId switch - { - "00000003-0000-0000-c000-000000000000" => "Microsoft Graph", - "00000002-0000-0000-c000-000000000000" => "Azure Active Directory Graph", - "797f4846-ba00-4fd7-ba43-dac1f8f63013" => "Azure Service Management", - "00000001-0000-0000-c000-000000000000" => "Azure ESTS Service", - _ => $"Unknown Resource ({resourceAppId})" - }; - } - private static async Task EnsureMcpOauth2PermissionGrantsAsync( GraphApiService graph, Agent365Config cfg, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs new file mode 100644 index 00000000..06350146 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +namespace Microsoft.Agents.A365.DevTools.Cli.Constants +{ + public static class ErrorCodes + { + public const string AzureAuthFailed = "AZURE_AUTH_FAILED"; + public const string PythonNotFound = "PYTHON_NOT_FOUND"; + public const string DeploymentAppFailed = "DEPLOYMENT_APP_FAILED"; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/AzureExceptions.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/AzureExceptions.cs index cb155077..d8445684 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/AzureExceptions.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/AzureExceptions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Constants; + namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; /// @@ -11,7 +13,7 @@ public class AzureAuthenticationException : Agent365Exception { public AzureAuthenticationException(string reason) : base( - errorCode: "AZURE_AUTH_FAILED", + errorCode: ErrorCodes.AzureAuthFailed, issueDescription: "Azure CLI authentication failed", errorDetails: new List { reason }, mitigationSteps: new List diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/DeployAppException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/DeployAppException.cs new file mode 100644 index 00000000..189a068d --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/DeployAppException.cs @@ -0,0 +1,28 @@ +// 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 during app deployment. +/// +public class DeployAppException : Agent365Exception +{ + private const string DeployAppIssueDescription = "App Deployment failed"; + + public DeployAppException(string reason) + : base( + errorCode: ErrorCodes.DeploymentAppFailed, + issueDescription: DeployAppIssueDescription, + errorDetails: new List { reason }, + mitigationSteps: new List + { + "Please review the logs and retry the deployment", + }) + { + } + + public override int ExitCode => 1; // General deployment error +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/PythonLocatorException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/PythonLocatorException.cs new file mode 100644 index 00000000..4ca73066 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/PythonLocatorException.cs @@ -0,0 +1,29 @@ +// 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 Python Locator fails to find a valid Python installation. +/// +public class PythonLocatorException : Agent365Exception +{ + private const string PythonLocatorIssueDescription = "Python Locator failed"; + + public PythonLocatorException(string reason) + : base( + errorCode: ErrorCodes.PythonNotFound, + issueDescription: PythonLocatorIssueDescription, + errorDetails: new List { reason }, + mitigationSteps: new List + { + "Python not found. Please install Python from https://www.python.org/. If you have already installed it, please include it in path", + "Ensure pip is installed with Python", + }) + { + } + + public override int ExitCode => 2; // Configuration error +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 074a1f4e..58365abd 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -111,12 +111,10 @@ static async Task Main(string[] args) { // Unexpected error - this is a BUG, show full stack trace Log.Fatal(ex, "Application terminated unexpectedly"); - Console.ForegroundColor = ConsoleColor.Red; 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(); - Console.ResetColor(); return 1; } finally @@ -131,9 +129,6 @@ static async Task Main(string[] args) /// private static void HandleAgent365Exception(Exceptions.Agent365Exception ex) { - // Set console color based on error severity - Console.ForegroundColor = ConsoleColor.Red; - // Display formatted error message Console.Error.Write(ex.GetFormattedMessage()); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index a5ba136f..346e3b7d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -14,7 +14,6 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// /// C# implementation fully equivalent to a365-createinstance.ps1. /// Supports all phases: Identity/User creation and License assignment. -/// Native C# implementation - no PowerShell script dependencies. /// MCP permissions are configured via inheritable permissions during setup phase. /// public sealed class A365CreateInstanceRunner @@ -187,12 +186,12 @@ string GetConfig(string name) => await SaveInstanceAsync(generatedConfigPath, instance, cancellationToken); _logger.LogInformation("Core inputs mapped and instance seed saved to {Path}", generatedConfigPath); - // ======================================================================== - // Phase 1: Agent Identity + Agent User Creation (Native C# Implementation) - // ======================================================================== + // ============================================== + // Phase 1: Agent Identity + Agent User Creation + // ============================================== if (step == "identity" || step == "all") { - _logger.LogInformation("Phase 1: Creating Agent Identity and Agent User (Native C# Implementation)"); + _logger.LogInformation("Phase 1: Creating Agent Identity and Agent User"); var agentIdentityDisplayName = GetConfig("agentIdentityDisplayName"); var agentUserDisplayName = GetConfig("agentUserDisplayName"); @@ -415,7 +414,7 @@ string GetConfig(string name) => // ============================ if (step == "licenses" || step == "all") { - _logger.LogInformation("Phase 2: License assignment (Native C# Implementation)"); + _logger.LogInformation("Phase 2: License assignment"); if (instance.TryGetPropertyValue("AgenticUserId", out var userIdNode)) { @@ -440,10 +439,6 @@ string GetConfig(string name) => return true; } - // ======================================================================== - // Native C# Implementation Methods (Replace PowerShell Scripts) - // ======================================================================== - /// /// Create Agent Identity using Microsoft Graph API /// Replaces createAgenticUser.ps1 (identity creation part) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs index 72e8e0e1..4e1c3770 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365SetupRunner.cs @@ -30,6 +30,7 @@ public sealed class A365SetupRunner 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, @@ -496,8 +497,7 @@ public async Task RunAsync(string configPath, string generatedConfigPath, } /// - /// Create Agent Blueprint using Microsoft Graph API (native C# implementation) - /// Replaces createAgentBlueprint.ps1 + /// 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. @@ -893,7 +893,7 @@ private async Task CreateFederatedIdentityCredentialAsync( lastError = error; // Check if it's a propagation issue (resource not found) - if ((error.Contains("Request_ResourceNotFound") || error.Contains("does not exist")) && attempt < maxRetries) + 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...", @@ -1060,7 +1060,6 @@ private async Task ConfigureMcpServerPermissionsAsync( /// /// Create a client secret for the Agent Blueprint using Microsoft Graph API. - /// Native C# implementation - no PowerShell dependencies. /// The secret is encrypted using DPAPI on Windows before storage. /// private async Task CreateBlueprintClientSecretAsync( @@ -1317,6 +1316,14 @@ private async Task ConfigureInheritablePermissionsAsync( { _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; } @@ -1324,7 +1331,16 @@ private async Task ConfigureInheritablePermissionsAsync( if (string.IsNullOrWhiteSpace(graphToken)) { _logger.LogError("Failed to acquire Graph API access token"); - throw new InvalidOperationException("Cannot update inheritable permissions without Graph API 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 @@ -1390,10 +1406,39 @@ private async Task ConfigureInheritablePermissionsAsync( } else { - _logger.LogError("Failed to configure Graph inheritable permissions: {Status} - {Error}", - graphResponse.StatusCode, error); - generatedConfig["inheritanceConfigured"] = false; - generatedConfig["graphInheritanceError"] = error; + // 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 @@ -1445,9 +1490,26 @@ private async Task ConfigureInheritablePermissionsAsync( } else { - _logger.LogError("Failed to configure Connectivity inheritable permissions: {Status} - {Error}", - connectivityResponse.StatusCode, error); - generatedConfig["connectivityInheritanceError"] = error; + // 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 diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs index d2f19508..548fbbab 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs @@ -121,98 +121,6 @@ public async Task EnsureBotServiceProviderAsync(string subscriptionId, str return (false, null, null, null); } - /// - /// Create or update Azure Bot Service with System-Assigned Managed Identity - /// - public async Task CreateOrUpdateBotWithSystemIdentityAsync( - string appServiceName, - string botName, - string resourceGroupName, - string subscriptionId, - string location, - string messagingEndpoint, - string agentDescription, - string sku) - { - _logger.LogInformation("Creating/updating Azure Bot Service with System-Assigned Identity..."); - _logger.LogDebug(" Bot Name: {BotName}", botName); - _logger.LogDebug(" Messaging Endpoint: {Endpoint}", messagingEndpoint); - - // Get the system-assigned identity from the app service - var identityArgs = $"webapp identity show --name {appServiceName} --resource-group {resourceGroupName} --query \"{{principalId:principalId, tenantId:tenantId}}\" --output json"; - var identityResult = await _executor.ExecuteAsync("az", identityArgs, captureOutput: true); - - if (identityResult == null) - { - _logger.LogError("Failed to execute identity command for app service {AppServiceName} - null result", appServiceName); - return false; - } - - if (!identityResult.Success || string.IsNullOrWhiteSpace(identityResult.StandardOutput)) - { - _logger.LogError("Cannot get system-assigned identity from app service {AppServiceName}", appServiceName); - return false; - } - - try - { - var identity = JsonSerializer.Deserialize(identityResult.StandardOutput); - var principalId = identity.GetProperty("principalId").GetString(); - var tenantId = identity.GetProperty("tenantId").GetString(); - - if (string.IsNullOrEmpty(principalId) || string.IsNullOrEmpty(tenantId)) - { - _logger.LogError("App service {AppServiceName} does not have a system-assigned identity", appServiceName); - _logger.LogError(" Please enable system-assigned identity first"); - return false; - } - - _logger.LogDebug("Found system-assigned identity"); - _logger.LogDebug(" Principal ID: {PrincipalId}", principalId); - - // Check if bot exists (suppress error logging for expected "not found") - var checkArgs = $"bot show --resource-group {resourceGroupName} --name {botName} --subscription {subscriptionId} --query id --output tsv"; - var checkResult = await _executor.ExecuteAsync("az", checkArgs, captureOutput: true, suppressErrorLogging: true); - - if (checkResult.Success && !string.IsNullOrWhiteSpace(checkResult.StandardOutput)) - { - _logger.LogInformation("Bot already exists, updating configuration..."); - return await UpdateBotAsync(botName, resourceGroupName, subscriptionId, messagingEndpoint); - } - - // Create new bot with system-assigned identity - _logger.LogInformation("Creating new Azure Bot with system-assigned identity..."); - - var createArgs = $"bot create " + - $"--resource-group {resourceGroupName} " + - $"--name {botName} " + - $"--app-type SingleTenant " + - $"--appid {principalId} " + - $"--tenant-id {tenantId} " + - $"--location {location} " + - $"--endpoint \"{messagingEndpoint}\" " + - $"--description \"{agentDescription}\" " + - $"--sku {sku}"; - - var createResult = await _executor.ExecuteAsync("az", createArgs, captureOutput: true); - - if (createResult.Success) - { - _logger.LogInformation("Azure Bot created successfully with system-assigned identity"); - return true; - } - - _logger.LogError("Failed to create Azure Bot"); - _logger.LogError(" Error: {Error}", createResult.StandardError); - return false; - } - catch (JsonException ex) - { - _logger.LogError("Failed to parse identity information: {Message}", ex.Message); - return false; - } - } - /// /// Create or update Azure Bot with Agent Blueprint Identity /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs index a93c8940..a658b8a4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs @@ -36,6 +36,17 @@ public ConfigurationWizardService( _logger = logger; } + private static string ExtractDomainFromAccount(AzureAccountInfo accountInfo) + { + if (!string.IsNullOrWhiteSpace(accountInfo?.User?.Name) && accountInfo.User.Name.Contains("@")) + { + var parts = accountInfo.User.Name.Split('@'); + if (parts.Length == 2 && !string.IsNullOrWhiteSpace(parts[1])) + return parts[1]; + } + return string.Empty; + } + public async Task RunWizardAsync(Agent365Config? existingConfig = null) { try @@ -77,7 +88,8 @@ public ConfigurationWizardService( return null; } - var derivedNames = GenerateDerivedNames(agentName); + var domain = ExtractDomainFromAccount(accountInfo); + var derivedNames = GenerateDerivedNames(agentName, domain); // Step 4: Validate deployment project path var deploymentPath = await PromptForDeploymentPathAsync(existingConfig); @@ -104,7 +116,7 @@ public ConfigurationWizardService( } // Step 7: Get manager email (required for agent creation) - var managerEmail = PromptForManagerEmail(existingConfig); + var managerEmail = PromptForManagerEmail(existingConfig, accountInfo); if (string.IsNullOrWhiteSpace(managerEmail)) { _logger.LogError("Configuration wizard cancelled: Manager email not provided"); @@ -368,11 +380,11 @@ private async Task PromptForAppServicePlanAsync(Agent365Config? existing } } - private string PromptForManagerEmail(Agent365Config? existingConfig) + private string PromptForManagerEmail(Agent365Config? existingConfig, AzureAccountInfo accountInfo) { return PromptWithDefault( "Manager email", - existingConfig?.ManagerEmail ?? "", + accountInfo?.User?.Name ?? "", ValidateEmail ); } @@ -396,17 +408,29 @@ private async Task PromptForLocationAsync(Agent365Config? existingConfig ); } - private ConfigDerivedNames GenerateDerivedNames(string agentName) + private static string GenerateValidWebAppName(string cleanName, string timestamp) + { + // Reserve 9 chars for "-webapp-" and 9 for "-endpoint" (total 18), so max cleanName+timestamp is 33 + // "-webapp-" is 8 chars, so cleanName+timestamp max is 33 + var baseName = $"{cleanName}-webapp"; + if (baseName.Length > 33) + baseName = baseName.Substring(0, 33); + if (baseName.Length < 2) + baseName = baseName.PadRight(2, 'a'); // pad to min length + return baseName; + } + + private ConfigDerivedNames GenerateDerivedNames(string agentName, string domain) { var cleanName = System.Text.RegularExpressions.Regex.Replace(agentName, @"[^a-zA-Z0-9]", "").ToLowerInvariant(); var timestamp = DateTime.Now.ToString("MMddHHmm"); - + var webAppName = GenerateValidWebAppName(cleanName, timestamp); return new ConfigDerivedNames { - WebAppName = $"{cleanName}-webapp-{timestamp}", + WebAppName = webAppName, AgentIdentityDisplayName = $"{agentName} Identity", AgentBlueprintDisplayName = $"{agentName} Blueprint", - AgentUserPrincipalName = $"agent.{cleanName}.{timestamp}@yourdomain.onmicrosoft.com", + AgentUserPrincipalName = $"UPN.{cleanName}@{domain}", AgentUserDisplayName = $"{agentName} Agent User" }; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs index f2b28486..0f3a7f78 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.IO.Compression; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Extensions.Logging; +using System.IO.Compression; namespace Microsoft.Agents.A365.DevTools.Cli.Services; @@ -189,7 +190,7 @@ private async Task DeployToAzureAsync(DeploymentConfiguration config, string pro { _logger.LogError("Deployment error: {Error}", deployResult.StandardError); } - throw new Exception($"Azure deployment failed: {deployResult.StandardError}"); + throw new DeployAppException($"Azure deployment failed: {deployResult.StandardError}"); } _logger.LogInformation(""); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/PythonLocator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/PythonLocator.cs new file mode 100644 index 00000000..20702eb2 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/PythonLocator.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Services; +using System.Runtime.InteropServices; + +public static class PythonLocator +{ + public static async Task FindPythonExecutableAsync(CommandExecutor executor) + { + // 1. Try PATH first (fastest) + var pathPython = await TryFindInPathAsync(executor); + if (pathPython != null) return pathPython; + + // 2. Search common installation directories + var commonPython = FindInCommonLocations(); + if (commonPython != null) return commonPython; + + // 3. Windows: Try Python Launcher as last resort + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var launcherPython = await TryPythonLauncherAsync(executor); + if (launcherPython != null) return launcherPython; + } + + return null; + } + + private static async Task TryFindInPathAsync(CommandExecutor executor) + { + string[] commands = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new[] { "python", "python3" } + : new[] { "python3", "python" }; + + foreach (var cmd in commands) + { + var result = await executor.ExecuteAsync( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "where" : "which", + cmd, + captureOutput: true, + suppressErrorLogging: true); + + if (result.Success && !string.IsNullOrWhiteSpace(result.StandardOutput)) + { + var path = result.StandardOutput + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault()?.Trim(); + + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + return path; + } + } + return null; + } + + private static string? FindInCommonLocations() + { + var candidates = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? GetWindowsCandidates() + : GetUnixCandidates(); + + return candidates.FirstOrDefault(File.Exists); + } + + private static IEnumerable GetWindowsCandidates() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + + // Microsoft Store (most common for newer Windows) + yield return Path.Combine(localAppData, @"Microsoft\WindowsApps\python.exe"); + yield return Path.Combine(localAppData, @"Microsoft\WindowsApps\python3.exe"); + + // Standard Python.org installations (3.13 down to 3.8) + for (int ver = 313; ver >= 38; ver--) + { + yield return $@"C:\Python{ver}\python.exe"; + yield return Path.Combine(localAppData, $@"Programs\Python\Python{ver}\python.exe"); + yield return Path.Combine(programFiles, $@"Python{ver}\python.exe"); + } + } + + private static IEnumerable GetUnixCandidates() + { + return new[] + { + "/usr/bin/python3", + "/usr/local/bin/python3", + "/opt/homebrew/bin/python3", // macOS ARM (M1/M2/M3) + "/opt/local/bin/python3", // MacPorts + "/usr/bin/python", + "/usr/local/bin/python" + }; + } + + private static async Task TryPythonLauncherAsync(CommandExecutor executor) + { + // py.exe can locate Python even if not in PATH + var result = await executor.ExecuteAsync( + "py", + "-3 -c \"import sys; print(sys.executable)\"", + captureOutput: true, + suppressErrorLogging: true); + + if (result.Success && !string.IsNullOrWhiteSpace(result.StandardOutput)) + { + var path = result.StandardOutput.Trim(); + if (File.Exists(path)) return path; + } + return null; + } +} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs index e7187135..c8aaaafc 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs @@ -26,12 +26,11 @@ public sealed class InteractiveGraphAuthService // Microsoft Graph PowerShell app ID (first-party Microsoft app with elevated privileges) private const string PowerShellAppId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; - // Scopes required for Agent Blueprint creation + // Scopes required for Agent Blueprint creation and inheritable permissions configuration private static readonly string[] RequiredScopes = new[] { "https://graph.microsoft.com/Application.ReadWrite.All", - "https://graph.microsoft.com/Directory.ReadWrite.All", - "https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All" + "https://graph.microsoft.com/AgentIdentityBlueprint.ReadWrite.All" }; public InteractiveGraphAuthService(ILogger logger) @@ -42,15 +41,13 @@ public InteractiveGraphAuthService(ILogger logger) /// /// Gets an authenticated GraphServiceClient using interactive browser authentication. /// This uses the Microsoft Graph PowerShell app ID to get the same elevated privileges. - /// - /// PURE C# - NO POWERSHELL REQUIRED /// public Task GetAuthenticatedGraphClientAsync( string tenantId, CancellationToken cancellationToken = default) { _logger.LogInformation("Attempting to authenticate to Microsoft Graph interactively..."); - _logger.LogInformation("This requires Application.ReadWrite.All permission which is needed for Agent Blueprints."); + _logger.LogInformation("This requires Application.ReadWrite.All and AgentIdentityBlueprint.ReadWrite.All permissions for Agent Blueprint operations."); _logger.LogInformation(""); _logger.LogInformation("IMPORTANT: A browser window will open for authentication."); _logger.LogInformation("Please sign in with an account that has Global Administrator or similar privileges."); @@ -63,7 +60,7 @@ public Task GetAuthenticatedGraphClientAsync( var credential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions { TenantId = tenantId, - ClientId = PowerShellAppId, // Use same app ID as PowerShell for consistency + ClientId = PowerShellAppId, AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, // Redirect URI for interactive browser auth (standard for public clients) RedirectUri = new Uri("http://localhost") @@ -71,9 +68,8 @@ public Task GetAuthenticatedGraphClientAsync( _logger.LogInformation("Opening browser for authentication..."); _logger.LogInformation("IMPORTANT: You must grant consent for the following permissions:"); - _logger.LogInformation(" - Application.ReadWrite.All"); - _logger.LogInformation(" - Directory.ReadWrite.All"); - _logger.LogInformation(" - DelegatedPermissionGrant.ReadWrite.All"); + _logger.LogInformation(" - Application.ReadWrite.All (for creating applications and blueprints)"); + _logger.LogInformation(" - AgentIdentityBlueprint.ReadWrite.All (for configuring inheritable permissions)"); _logger.LogInformation(""); // Create GraphServiceClient with the credential diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/PythonBuilder.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/PythonBuilder.cs index 90d3234b..676a9fd5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/PythonBuilder.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/PythonBuilder.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Extensions.Logging; @@ -13,6 +14,7 @@ public class PythonBuilder : IPlatformBuilder { private readonly ILogger _logger; private readonly CommandExecutor _executor; + private string ? _pythonExe; public PythonBuilder(ILogger logger, CommandExecutor executor) { @@ -23,19 +25,26 @@ public PythonBuilder(ILogger logger, CommandExecutor executor) public async Task ValidateEnvironmentAsync() { _logger.LogInformation("Validating Python environment..."); - - var pythonResult = await _executor.ExecuteAsync("python", "--version", captureOutput: true); + + _pythonExe = await PythonLocator.FindPythonExecutableAsync(_executor); + if (string.IsNullOrWhiteSpace(_pythonExe)) + { + _logger.LogError("Python not found. Please install Python from https://www.python.org/"); + throw new PythonLocatorException("Python executable could not be located."); + } + + var pythonResult = await _executor.ExecuteAsync(_pythonExe, "--version", captureOutput: true); if (!pythonResult.Success) { _logger.LogError("Python not found. Please install Python from https://www.python.org/"); - return false; - } - - var pipResult = await _executor.ExecuteAsync("pip", "--version", captureOutput: true); + throw new PythonLocatorException("Python executable could not be located."); + } + + var pipResult = await _executor.ExecuteAsync(_pythonExe, "-m pip --version", captureOutput: true); if (!pipResult.Success) { _logger.LogError("pip not found. Please ensure pip is installed with Python."); - return false; + throw new PythonLocatorException("Unable to locate pip."); } _logger.LogInformation("Python version: {Version}", pythonResult.StandardOutput.Trim()); @@ -201,9 +210,15 @@ public async Task CreateManifestAsync(string projectDir, string pu } } else - { - // Try to get from current python - var versionResult = await _executor.ExecuteAsync("python", "--version", captureOutput: true); + { + // Try to get from current python + _pythonExe = _pythonExe ?? await PythonLocator.FindPythonExecutableAsync(_executor); + if (string.IsNullOrWhiteSpace(_pythonExe)) + { + _logger.LogError("Python not found. Please install Python from https://www.python.org/"); + throw new PythonLocatorException("Python executable could not be located."); + } + var versionResult = await _executor.ExecuteAsync(_pythonExe, "--version", captureOutput: true); if (versionResult.Success) { var match = System.Text.RegularExpressions.Regex.Match( diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/BotConfiguratorTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/BotConfiguratorTests.cs index 83db6820..b79799d6 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/BotConfiguratorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/BotConfiguratorTests.cs @@ -419,104 +419,6 @@ public async Task ConfigureMsTeamsChannelAsync_CreationFails_ReturnsFalse() Assert.False(result); } - [Fact] - public async Task CreateOrUpdateBotWithSystemIdentityAsync_IdentityDoesNotExist_ReturnsFalse() - { - // Arrange - var appServiceName = "test-app-service"; - var botName = "test-bot"; - var resourceGroupName = "test-resource-group"; - var subscriptionId = "test-subscription"; - var location = "westus2"; - var messagingEndpoint = "https://test.azurewebsites.net/api/messages"; - var description = "Test Bot Description"; - var sku = "F0"; - - var identityCheckResult = new CommandResult { ExitCode = 1, StandardError = "Identity not found" }; - - _executor.ExecuteAsync( - Arg.Is("az"), - Arg.Is(s => s.Contains($"webapp identity show --name {appServiceName} --resource-group {resourceGroupName}")), - Arg.Any(), - Arg.Is(true), - Arg.Is(false), - Arg.Any()) - .Returns(Task.FromResult(identityCheckResult)); - - // Act - var result = await _configurator.CreateOrUpdateBotWithSystemIdentityAsync( - appServiceName, botName, resourceGroupName, subscriptionId, location, messagingEndpoint, description, sku); - - // Assert - Assert.False(result); - } - - [Fact] - public async Task CreateOrUpdateBotWithSystemIdentityAsync_BotCreationSucceeds_ReturnsTrue() - { - // Arrange - var appServiceName = "test-app-service"; - var botName = "test-bot"; - var resourceGroupName = "test-resource-group"; - var subscriptionId = "test-subscription"; - var location = "westus2"; - var messagingEndpoint = "https://test.azurewebsites.net/api/messages"; - var description = "Test Bot Description"; - var sku = "F0"; - - var identityResult = new CommandResult - { - ExitCode = 0, - StandardOutput = """ - { - "principalId": "test-principal-id", - "tenantId": "test-tenant-id" - } - """ - }; - - var botCheckResult = new CommandResult { ExitCode = 1, StandardError = "Bot not found" }; - var botCreateResult = new CommandResult - { - ExitCode = 0, - StandardOutput = """{"name": "test-bot"}""" - }; - - _executor.ExecuteAsync( - Arg.Is("az"), - Arg.Is(s => s.Contains($"webapp identity show --name {appServiceName} --resource-group {resourceGroupName}")), - Arg.Any(), - Arg.Is(true), - Arg.Is(false), - Arg.Any()) - .Returns(Task.FromResult(identityResult)); - - _executor.ExecuteAsync( - Arg.Is("az"), - Arg.Is(s => s.Contains($"bot show --resource-group {resourceGroupName} --name {botName} --subscription {subscriptionId}")), - Arg.Any(), - Arg.Is(true), - Arg.Is(true), - Arg.Any()) - .Returns(Task.FromResult(botCheckResult)); - - _executor.ExecuteAsync( - Arg.Is("az"), - Arg.Is(s => s.Contains($"bot create --resource-group {resourceGroupName} --name {botName}")), - Arg.Any(), - Arg.Is(true), - Arg.Is(false), - Arg.Any()) - .Returns(Task.FromResult(botCreateResult)); - - // Act - var result = await _configurator.CreateOrUpdateBotWithSystemIdentityAsync( - appServiceName, botName, resourceGroupName, subscriptionId, location, messagingEndpoint, description, sku); - - // Assert - Assert.True(result); - } - [Fact] public async Task CreateOrUpdateBotWithAgentBlueprintAsync_IdentityDoesNotExist_ReturnsFalse() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ConfigurationWizardServiceWebAppNameTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ConfigurationWizardServiceWebAppNameTests.cs new file mode 100644 index 00000000..a2b16d8e --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ConfigurationWizardServiceWebAppNameTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using FluentAssertions; +using Xunit; +using NSubstitute; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +public class ConfigurationWizardServiceWebAppNameTests +{ + [Theory] + [InlineData("a", "01010000", 8)] + [InlineData("abcdefghijklmnopqrstuvwxyz0123456789", "01010000", 33)] // too long, should truncate + [InlineData("abc", "01010000", 10)] // normal + public void GenerateValidWebAppName_EnforcesLength(string cleanName, string timestamp, int expectedLength) + { + var method = typeof(ConfigurationWizardService) + .GetMethod("GenerateValidWebAppName", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + method.Should().NotBeNull(); + var result = method!.Invoke(null, new object[] { cleanName, timestamp }) as string; + result.Should().NotBeNull(); + result!.Length.Should().Be(expectedLength); + result.Should().MatchRegex("^[a-z0-9-]+$"); + } + + [Fact] + public void GenerateDerivedNames_WebAppName_AlwaysValidLength() + { + var azureCli = Substitute.For(); + var platformDetector = Substitute.For(Substitute.For>()); + var logger = Substitute.For>(); + var svc = new ConfigurationWizardService(azureCli, platformDetector, logger); + var method = svc.GetType().GetMethod("GenerateDerivedNames", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + method.Should().NotBeNull(); + for (int i = 1; i < 60; i++) + { + var agentName = new string('a', i); + var derived = method!.Invoke(svc, new object[] { agentName, "contoso.com" }); + derived.Should().NotBeNull(); + var webAppName = (string)derived!.GetType().GetProperty("WebAppName")!.GetValue(derived)!; + webAppName.Length.Should().BeGreaterOrEqualTo(2); + webAppName.Length.Should().BeLessOrEqualTo(33); + } + } + + [Theory] + [InlineData("sellak@testcsaaa.onmicrosoft.com", "testcsaaa.onmicrosoft.com")] + [InlineData("user@contoso.com", "contoso.com")] + [InlineData("admin@sub.domain.com", "sub.domain.com")] + [InlineData("invalid", "")] + [InlineData("", "")] + public void ExtractDomainFromAccount_HandlesVariousCases(string accountName, string expectedDomain) + { + var method = typeof(ConfigurationWizardService) + .GetMethod("ExtractDomainFromAccount", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + method.Should().NotBeNull(); + var accountInfo = new AzureAccountInfo { Name = accountName, User = new AzureUser { Name = accountName } }; + var result = method!.Invoke(null, new object[] { accountInfo }) as string; + result.Should().Be(expectedDomain); + } + + [Fact] + public void GenerateDerivedNames_UsesDomainInUPN() + { + var azureCli = Substitute.For(); + var platformDetector = Substitute.For(Substitute.For>()); + var logger = Substitute.For>(); + var svc = new ConfigurationWizardService(azureCli, platformDetector, logger); + var method = svc.GetType().GetMethod("GenerateDerivedNames", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + method.Should().NotBeNull(); + var agentName = "agent"; + var domain = "contoso.com"; + var derived = method!.Invoke(svc, new object[] { agentName, domain }); + derived.Should().NotBeNull(); + var upn = (string)derived!.GetType().GetProperty("AgentUserPrincipalName")!.GetValue(derived)!; + upn.Should().EndWith("@contoso.com"); + } +}