From 325f2d05695ae8dbb21624e6c4d48d31dd955105 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Tue, 5 May 2026 16:21:07 -0700 Subject: [PATCH 1/2] Fix blueprint setup consent flow and role accuracy (#403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DelegatedConsentService: return false on 4xx PATCH failures instead of silently returning true; check return value in caller and surface an actionable error with a v2.0/adminconsent URL when a non-admin cannot update an existing permission grant - SetupHelpers: fix pendingAdminAction regression (commit 650ebdb) that hid the consent URL for DW agents when S2S grants were also pending - SetupHelpers: replace removed Maven.ReadWrite.All scope with ObservabilityApiOtelWriteScope in consent URLs - AuthenticationConstants: correct S2SGrantRequiredRoles — Agent ID Administrator cannot create S2S app role assignments (verified by live testing); role list now matches DelegatedGrantRequiredRoles - CleanupCommand: surface actionable error message when cleanup is run without --agent-name and no config file is present Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/CleanupCommand.cs | 6 ++-- .../Commands/SetupSubcommands/SetupHelpers.cs | 6 ++-- .../Constants/AuthenticationConstants.cs | 5 ++-- .../Constants/ConfigConstants.cs | 11 +------- .../Services/DelegatedConsentService.cs | 28 +++++++++++++++---- .../Helpers/SetupHelpersConsentUrlTests.cs | 8 +++--- 6 files changed, 35 insertions(+), 29 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs index 8123786e..5e439fee 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs @@ -91,8 +91,6 @@ public static Command CreateCommand( } else { - // No --agent-name and no static config file — fail fast with a clear exit code - // so cleanup does not silently report success to scripts or CI. bootstrapConfig = await LoadConfigAsync(configFile, logger, configService); if (bootstrapConfig is null) { @@ -1362,9 +1360,9 @@ private static void PrintOrphanSummary( logger.LogInformation("Loaded configuration successfully from {ConfigFile}", configPath); return config; } - catch (ConfigFileNotFoundException ex) + catch (ConfigFileNotFoundException) { - logger.LogError("{Message}", ex.IssueDescription); + logger.LogError("Specify the agent to clean up: a365 cleanup --agent-name "); return null; } catch (Exception ex) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs index 5dcf3bd1..4fec6b29 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -456,7 +456,7 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger, boo var permissionGrantsPending = isS2SFlow ? results.S2SAppRoleGranted == false : !permissionGrantsCompleted && results.BatchPermissionsPhase2Completed; - var pendingAdminAction = permissionGrantsPending && !isS2SFlow && !isNonDw; + var pendingAdminAction = !isNonDw && !results.AdminConsentGranted && results.BatchPermissionsPhase2Completed; var pendingS2SAction = permissionGrantsPending && isS2SFlow; var pendingDelegatedAction = results.AgentIdentityDelegatedGrantPending; @@ -940,7 +940,7 @@ static string Build(string tenant, string client, string resourceUri, IEnumerabl } // Observability API is required for both DW and non-DW paths. - urls.Add(("Observability API", Build(tenantId, blueprintClientId, ConfigConstants.ObservabilityApiIdentifierUri, new[] { ConfigConstants.ObservabilityApiAdminConsentScope }))); + urls.Add(("Observability API", Build(tenantId, blueprintClientId, ConfigConstants.ObservabilityApiIdentifierUri, new[] { ConfigConstants.ObservabilityApiOtelWriteScope }))); urls.Add(("Power Platform API", Build(tenantId, blueprintClientId, PowerPlatformConstants.PowerPlatformApiIdentifierUri, new[] { PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead }))); return urls; @@ -972,7 +972,7 @@ internal static string BuildCombinedConsentUrl( foreach (var s in mcpScopes) allScopes.Add($"{McpConstants.Agent365ToolsIdentifierUri}/{s}"); allScopes.Add($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}"); - allScopes.Add($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiAdminConsentScope}"); + allScopes.Add($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"); } allScopes.Add($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}"); return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs index ed36e283..727c08da 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs @@ -271,10 +271,9 @@ public static string[] GetRequiredRedirectUris(string clientAppId) /// /// Entra roles that can perform S2S app role assignments programmatically. - /// All three roles have been verified to work with . - /// Listed in order of least privilege: Agent ID Administrator, Application Administrator, Global Administrator. + /// Verified: Agent ID Administrator cannot create S2S app role assignments (403). Application Administrator and Global Administrator confirmed working. /// - public const string S2SGrantRequiredRoles = "Agent ID Administrator, Application Administrator, or Global Administrator"; + public const string S2SGrantRequiredRoles = "Application Administrator or Global Administrator"; /// /// Roles required to create tenant-wide AllPrincipals oauth2PermissionGrants via the PowerShell diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index c4359a10..ad72a98c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -88,18 +88,9 @@ public static class ConfigConstants /// public const string MessagingBotApiAdminConsentScope = "AgentData.ReadWrite"; - /// - /// Observability API scope used in admin consent URLs. - /// This is the only scope published by the Observability API resource app manifest - /// that is valid for the /v2.0/adminconsent endpoint. - /// Note: OtelWrite causes AADSTS650053 in the consent URL flow; OtelWrite is granted - /// separately via OAuth2PermissionGrants. - /// - public const string ObservabilityApiAdminConsentScope = "Maven.ReadWrite.All"; - /// /// Observability API scope for writing OpenTelemetry data. - /// Granted to all provisioned agent identities via OAuth2PermissionGrants. + /// Used in admin consent URLs and granted to provisioned agent identities via OAuth2PermissionGrants. /// public const string ObservabilityApiOtelWriteScope = "Agent365.Observability.OtelWrite"; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs index be9ee206..b782d36d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs @@ -112,7 +112,20 @@ public async Task EnsureBlueprintPermissionGrantAsync( // Update existing grant(s) to include required scope foreach (var grant in existingGrants) { - await EnsureScopeOnGrantAsync(httpClient, grant, TargetScope, cancellationToken); + var updated = await EnsureScopeOnGrantAsync(httpClient, grant, TargetScope, cancellationToken); + if (!updated) + { + var scopeUri = Uri.EscapeDataString($"{AuthenticationConstants.MicrosoftGraphResourceUri}/{TargetScope}"); + var redirectUri = Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri); + var consentUrl = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={callingAppId}&scope={scopeUri}&redirect_uri={redirectUri}"; + _logger.LogError( + "The existing permission grant could not be updated to include '{Scope}'. " + + "An administrator ({Roles}) must grant admin consent. " + + "Share this URL with an administrator to grant consent: {ConsentUrl}", + TargetScope, AuthenticationConstants.DelegatedGrantRequiredRoles, consentUrl); + _logger.LogError("After consent is granted, re-run the command."); + return false; + } } } else @@ -453,10 +466,15 @@ private async Task EnsureScopeOnGrantAsync( if (!updateResponse.IsSuccessStatusCode) { var error = await updateResponse.Content.ReadAsStringAsync(cancellationToken); - _logger.LogDebug("Grant update returned error (may be transient): {Error}", error); - // Note: We return true here because the grant update failure is often transient - // and the setup can continue. The "Successfully ensured grant" message below - // indicates the overall operation succeeded even if this specific update had issues. + if ((int)updateResponse.StatusCode is >= 400 and < 500) + { + // Non-transient failure (e.g., 403 Forbidden — caller lacks DelegatedPermissionGrant.ReadWrite.All). + // Return false so the caller can surface a clear, actionable error. + _logger.LogError("Failed to update permission grant {GrantId} (HTTP {Status}): {Error}", grantId, (int)updateResponse.StatusCode, error); + return false; + } + // Transient server-side error — caller may retry. + _logger.LogDebug("Grant update returned transient error (HTTP {Status}): {Error}", (int)updateResponse.StatusCode, error); return true; } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs index 17551e0e..8f3589cf 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs @@ -68,8 +68,8 @@ public void BuildAdminConsentUrls_ObservabilityApi_UsesCorrectScopeConstant() var urls = SetupHelpers.BuildAdminConsentUrls(TenantId, BlueprintClientId, new[] { "Mail.Send" }, new[] { "scope" }); var obsUrl = urls.First(u => u.ResourceName == "Observability API").ConsentUrl; - obsUrl.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiAdminConsentScope}"), - because: "Maven.ReadWrite.All is the only scope published in the Observability API manifest valid for /v2.0/adminconsent — OtelWrite and user_impersonation cause AADSTS650053 in the consent URL flow (those are granted separately via OAuth2PermissionGrants)"); + obsUrl.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"), + because: "OtelWrite is the published delegated scope on the Observability API used for admin consent"); } [Fact] @@ -220,8 +220,8 @@ public void BuildCombinedConsentUrl_AlwaysIncludesAllThreeFixedResources() url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}"), because: "scope URIs are Uri.EscapeDataString-encoded in the query string — required by AAD for adminconsent"); - url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiAdminConsentScope}"), - because: "Maven.ReadWrite.All is the only scope valid for /v2.0/adminconsent on the Observability API resource — OtelWrite causes AADSTS650053"); + url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"), + because: "OtelWrite is the published delegated scope on the Observability API used for admin consent"); url.Should().Contain(Uri.EscapeDataString($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}")); } From 954cb5482c641e95b6793957db8e457302a7195b Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Tue, 5 May 2026 16:50:02 -0700 Subject: [PATCH 2/2] Address review comments on PR #405: publish path and consent service fixes - Remove PublishBlueprintNonDwAsync: a365 publish was silently calling POST /beta/agentRegistry/agentInstances (old v1 API, no idempotency) for blueprint non-DW agents. Registration belongs in setup all, which already uses the correct copilot/agentRegistrations v2 endpoint. Blueprint path now directs users to a365 setup all. - Fix redirect_uri in DelegatedConsentService fallback consent URL: BlueprintConsentRedirectUri is not registered on customer client apps, causing AADSTS500113. Omit redirect_uri so the URL works for any app. - Fix 5xx treated as success in EnsureScopeOnGrantAsync: transient PATCH failures now return false so the caller surfaces an actionable error rather than silently reporting consent as granted. - Add regression tests for DW pendingAdminAction: consent URL must appear in Action Required when admin consent is pending on the DW path, including when S2S grants are also pending. Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/PublishCommand.cs | 95 ++----------------- .../Program.cs | 2 +- .../Services/DelegatedConsentService.cs | 9 +- .../NonDwPublishCommandDryRunTests.cs | 10 +- .../SetupHelpersDisplaySetupSummaryTests.cs | 52 ++++++++++ 5 files changed, 69 insertions(+), 99 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs index cbf6659c..5422159b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs @@ -55,7 +55,6 @@ public static Command CreateCommand( ILogger logger, IConfigService configService, ManifestTemplateService manifestTemplateService, - GraphApiService? graphApiService = null, IBootstrapConfigResolver? resolver = null) { var command = new Command("publish", "Update manifest IDs and create a package for upload to Microsoft 365 Admin Center"); @@ -78,7 +77,7 @@ public static Command CreateCommand( var useBlueprintOption = new Option( "--use-blueprint", - description: "Use the blueprint-based non-DW flow (calls Agent Instance Graph API, no manifest).\n" + + description: "Identifies this as a blueprint-based non-DW agent. Registration is handled by 'a365 setup all'.\n" + "Only meaningful with --aiteammate false"); var verboseOption = new Option( @@ -152,19 +151,18 @@ public static Command CreateCommand( { var isBlueprint = useBlueprintFlag || (isBlueprintAgent && config.UseBlueprint == true); - if (dryRun) + if (isBlueprint) { - if (isBlueprint) - PrintNonDwBlueprintDryRunPlan(config, logger); - else - PrintNonDwDryRunPlan(config, logger); + logger.LogInformation("Blueprint-based agent registration is handled by 'a365 setup all'."); + logger.LogInformation("Nothing to publish for blueprint-based agents. Run 'a365 setup all' to register."); isNormalExit = true; return; } - if (isBlueprint) + if (dryRun) { - isNormalExit = await PublishBlueprintNonDwAsync(config, graphApiService, configService, logger, context, ct: context.GetCancellationToken()); + PrintNonDwDryRunPlan(config, logger); + isNormalExit = true; return; } @@ -299,85 +297,6 @@ public static Command CreateCommand( return command; } - /// - /// Registers the agent instance via POST /beta/agentRegistry/agentInstances and saves - /// the returned instance ID to the generated config. Returns true on success. - /// - private static async Task PublishBlueprintNonDwAsync( - Agent365Config config, - GraphApiService? graphApiService, - IConfigService configService, - ILogger logger, - System.CommandLine.Invocation.InvocationContext context, - CancellationToken ct) - { - if (graphApiService == null) - { - logger.LogError("GraphApiService is not available. This is a configuration error."); - context.ExitCode = 1; - return false; - } - - if (string.IsNullOrWhiteSpace(config.TenantId)) - { - logger.LogError("tenantId is required for blueprint non-DW publish. Set it in a365.config.json."); - context.ExitCode = 1; - return false; - } - - if (string.IsNullOrWhiteSpace(config.AgentIdentityDisplayName)) - { - logger.LogError("agentIdentityDisplayName is required. Set it in a365.config.json."); - context.ExitCode = 1; - return false; - } - - logger.LogInformation("Registering agent instance..."); - logger.LogInformation(" POST /beta/agentRegistry/agentInstances"); - logger.LogInformation(" displayName : {DisplayName}", config.AgentIdentityDisplayName); - if (!string.IsNullOrWhiteSpace(config.AgentBlueprintId)) - logger.LogInformation(" agentIdentityBlueprintId: {BlueprintId}", config.AgentBlueprintId); - - var instanceId = await graphApiService.RegisterAgentInstanceAsync( - config.TenantId, - config.AgentIdentityDisplayName, - config.AgentBlueprintId, - ct); - - if (string.IsNullOrWhiteSpace(instanceId)) - { - logger.LogError("Agent instance registration failed."); - context.ExitCode = 1; - return false; - } - - logger.LogInformation("Agent instance registered: {InstanceId}", instanceId); - - config.AgentInstanceId = instanceId; - await configService.SaveStateAsync(config); - logger.LogInformation("Saved agentInstanceId to generated config."); - - return true; - } - - private static void PrintNonDwBlueprintDryRunPlan(Models.Agent365Config config, ILogger logger) - { - var blueprintId = !string.IsNullOrWhiteSpace(config.AgentBlueprintId) - ? config.AgentBlueprintId - : ""; - - logger.LogInformation("Non-DW Blueprint Publish Plan (dry run — no API calls will be made)"); - logger.LogInformation(""); - logger.LogInformation(" Agent Instance Registration"); - logger.LogInformation(" Call Agent Instance Graph API"); - logger.LogInformation(" Blueprint ID {BlueprintId}", blueprintId); - logger.LogInformation(" Tenant {TenantId}", config.TenantId); - logger.LogInformation(""); - logger.LogInformation(" No manifest or zip created for blueprint-based agents."); - logger.LogInformation(""); - logger.LogInformation("Run without --dry-run to register the agent instance."); - } - private static void PrintNonDwDryRunPlan(Models.Agent365Config config, ILogger logger) { var clientAppId = !string.IsNullOrWhiteSpace(config.ClientAppId) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 17c4b592..a20789d2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -159,7 +159,7 @@ await Task.WhenAll( var manifestTemplateService = serviceProvider.GetRequiredService(); rootCommand.AddCommand(QueryEntraCommand.CreateCommand(queryEntraLogger, configService, executor, graphApiService, agentBlueprintService, resolver: bootstrapResolver)); rootCommand.AddCommand(CleanupCommand.CreateCommand(cleanupLogger, configService, backendConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService, azureAuthValidator, graphApiService, resolver: bootstrapResolver)); - rootCommand.AddCommand(PublishCommand.CreateCommand(publishLogger, configService, manifestTemplateService, graphApiService, resolver: bootstrapResolver)); + rootCommand.AddCommand(PublishCommand.CreateCommand(publishLogger, configService, manifestTemplateService, resolver: bootstrapResolver)); // Build pipeline manually so we can skip UseTypoCorrections() ("Did you mean?" noise) // and UseParseErrorReporting() (full help dump on any parse error), replacing both diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs index b782d36d..fcb0e2f0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs @@ -116,8 +116,7 @@ public async Task EnsureBlueprintPermissionGrantAsync( if (!updated) { var scopeUri = Uri.EscapeDataString($"{AuthenticationConstants.MicrosoftGraphResourceUri}/{TargetScope}"); - var redirectUri = Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri); - var consentUrl = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={callingAppId}&scope={scopeUri}&redirect_uri={redirectUri}"; + var consentUrl = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={callingAppId}&scope={scopeUri}"; _logger.LogError( "The existing permission grant could not be updated to include '{Scope}'. " + "An administrator ({Roles}) must grant admin consent. " + @@ -473,9 +472,9 @@ private async Task EnsureScopeOnGrantAsync( _logger.LogError("Failed to update permission grant {GrantId} (HTTP {Status}): {Error}", grantId, (int)updateResponse.StatusCode, error); return false; } - // Transient server-side error — caller may retry. - _logger.LogDebug("Grant update returned transient error (HTTP {Status}): {Error}", (int)updateResponse.StatusCode, error); - return true; + // Transient server-side error — return false so the caller surfaces an actionable error rather than silently skipping the update. + _logger.LogWarning("Transient error updating permission grant {GrantId} (HTTP {Status}): {Error}", grantId, (int)updateResponse.StatusCode, error); + return false; } _logger.LogDebug(" Grant updated successfully"); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwPublishCommandDryRunTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwPublishCommandDryRunTests.cs index bf5a8c2c..ac154b8b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwPublishCommandDryRunTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwPublishCommandDryRunTests.cs @@ -219,16 +219,15 @@ public async Task Publish_BlueprintNonDwDryRun_ViaConfig_ReturnsExitCode0() } [Fact] - public async Task Publish_BlueprintNonDwDryRun_LogsBlueprintId() + public async Task Publish_BlueprintNonDw_LogsDirectsToSetupAll() { - const string blueprintId = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"; var config = new Agent365Config { AiTeammate = false, UseBlueprint = true, TenantId = "tenant-id", ClientAppId = "client-app-id", - AgentBlueprintId = blueprintId, + AgentBlueprintId = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", AgentIdentityDisplayName = "My Agent" }; _configService.LoadAsync().Returns(config); @@ -237,12 +236,13 @@ public async Task Publish_BlueprintNonDwDryRun_LogsBlueprintId() var root = new RootCommand(); root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); - await root.InvokeAsync("publish --dry-run"); + await root.InvokeAsync("publish"); + // blueprint-based agents should be directed to 'a365 setup all' instead of registering via publish _logger.Received().Log( LogLevel.Information, Arg.Any(), - Arg.Is(o => o.ToString()!.Contains(blueprintId)), + Arg.Is(o => o.ToString()!.Contains("setup all")), null, Arg.Any>()); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySetupSummaryTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySetupSummaryTests.cs index 6c6c0485..b52552c8 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySetupSummaryTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySetupSummaryTests.cs @@ -164,6 +164,58 @@ public void DisplaySetupSummary_PendingS2SAction_NonDw_EmbedsTenantId() because: "the Connect-MgGraph call must include -TenantId so the admin targets the correct tenant"); } + // ── pendingAdminAction (DW path) ────────────────────────────────────────── + + [Fact] + public void DisplaySetupSummary_DwAdminConsentPending_ShowsActionRequired() + { + var logger = new CapturingLogger(); + const string consentUrl = "https://login.microsoftonline.com/tenant/v2.0/adminconsent?client_id=bp-id"; + var results = new SetupResults + { + IsNonDwBlueprintFlow = false, + BlueprintCreated = true, + BlueprintId = BlueprintId, + TenantId = TenantId, + AdminConsentGranted = false, + BatchPermissionsPhase1Completed = true, + BatchPermissionsPhase2Completed = true, + CombinedConsentUrl = consentUrl, + }; + + SetupHelpers.DisplaySetupSummary(results, logger, isDw: true); + + logger.AllOutput.Should().Contain("Action Required", + because: "when DW admin consent is pending the summary must show an action item"); + logger.AllOutput.Should().Contain(consentUrl, + because: "the consent URL must appear in the action block so the admin can grant consent"); + } + + [Fact] + public void DisplaySetupSummary_DwAdminConsentPending_WithS2SAlsoPending_ShowsConsentUrl() + { + var logger = new CapturingLogger(); + const string consentUrl = "https://login.microsoftonline.com/tenant/v2.0/adminconsent?client_id=bp-id"; + var results = new SetupResults + { + IsNonDwBlueprintFlow = false, + BlueprintCreated = true, + BlueprintId = BlueprintId, + TenantId = TenantId, + AdminConsentGranted = false, + S2SAppRoleGranted = false, + EffectiveAuthMode = "both", + BatchPermissionsPhase1Completed = true, + BatchPermissionsPhase2Completed = true, + CombinedConsentUrl = consentUrl, + }; + + SetupHelpers.DisplaySetupSummary(results, logger, isDw: true); + + logger.AllOutput.Should().Contain(consentUrl, + because: "consent URL must appear even when S2S grants are also pending — regression guard for pendingAdminAction condition"); + } + // ── helpers ─────────────────────────────────────────────────────────────── private static SetupResults BuildDelegatedPendingResults() => new()