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");
+ }
+}