Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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 <name>");
return null;
}
catch (Exception ex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ public static Command CreateCommand(
ILogger<PublishCommand> 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");
Expand All @@ -78,7 +77,7 @@ public static Command CreateCommand(

var useBlueprintOption = new Option<bool>(
"--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<bool>(
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -299,85 +297,6 @@ public static Command CreateCommand(
return command;
}

/// <summary>
/// Registers the agent instance via POST /beta/agentRegistry/agentInstances and saves
/// the returned instance ID to the generated config. Returns true on success.
/// </summary>
private static async Task<bool> 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
: "<agentBlueprintId — run setup first>";

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
sellakumaran marked this conversation as resolved.
var pendingS2SAction = permissionGrantsPending && isS2SFlow;
var pendingDelegatedAction = results.AgentIdentityDelegatedGrantPending;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,10 +271,9 @@ public static string[] GetRequiredRedirectUris(string clientAppId)

/// <summary>
/// Entra roles that can perform S2S app role assignments programmatically.
/// All three roles have been verified to work with <see cref="RequiredS2SGrantScopes"/>.
/// 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.
/// </summary>
public const string S2SGrantRequiredRoles = "Agent ID Administrator, Application Administrator, or Global Administrator";
public const string S2SGrantRequiredRoles = "Application Administrator or Global Administrator";

/// <summary>
/// Roles required to create tenant-wide AllPrincipals oauth2PermissionGrants via the PowerShell
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,18 +88,9 @@ public static class ConfigConstants
/// </summary>
public const string MessagingBotApiAdminConsentScope = "AgentData.ReadWrite";

/// <summary>
/// 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.
/// </summary>
public const string ObservabilityApiAdminConsentScope = "Maven.ReadWrite.All";

/// <summary>
/// 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.
/// </summary>
public const string ObservabilityApiOtelWriteScope = "Agent365.Observability.OtelWrite";

Expand Down
2 changes: 1 addition & 1 deletion src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ await Task.WhenAll(
var manifestTemplateService = serviceProvider.GetRequiredService<ManifestTemplateService>();
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,19 @@ public async Task<bool> 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 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. " +
"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
Expand Down Expand Up @@ -453,11 +465,16 @@ private async Task<bool> 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.
return true;
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 — 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<EventId>(),
Arg.Is<object>(o => o.ToString()!.Contains(blueprintId)),
Arg.Is<object>(o => o.ToString()!.Contains("setup all")),
null,
Arg.Any<Func<object, Exception?, string>>());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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}"));
}

Expand Down
Loading
Loading