diff --git a/.claude/agents/pr-code-reviewer.md b/.claude/agents/pr-code-reviewer.md index bd140508..1aaf77d1 100644 --- a/.claude/agents/pr-code-reviewer.md +++ b/.claude/agents/pr-code-reviewer.md @@ -688,6 +688,73 @@ When a block of code — a method body, a collection initializer, a sequence of **Real example (from `users/sellak/blueprintScopes`):** `AllSubcommand.cs`, `AdminSubcommand.cs`, and `PermissionsSubcommand.cs` each contained an identical three-entry block for Bot API, Observability API, and Power Platform API. When `Agent365.Observability.OtelWrite` was added, the new scope had to be written in three places — and would have been missed without manual cross-file inspection. Extracted to `SetupHelpers.GetFixedApiPermissionSpecs(bool setInheritable)`. +### 23. Unconditional Success Log After Multiple Fallible Operations + +A success or completion log message is emitted unconditionally after a sequence of independent operations that each have their own `if (!ok)` warning branches. The final message claims the whole step succeeded regardless of which individual operations failed. + +- **Pattern to catch**: + - A sequence of: `var aOk = await DoA(...); if (!aOk) LogWarning(...); var bOk = await DoB(...); if (!bOk) LogWarning(...); LogInformation("completed successfully");` + - The success log appears at the end without checking `aOk && bOk` — it fires even if every preceding operation returned false + - Common in multi-grant admin consent flows, multi-step provisioning, and batch operations +- **Severity**: `high` — users see "completed successfully" in the terminal while one or more required operations silently failed; they have no indication follow-up action is needed +- **Check**: For every `LogInformation("...success..." or "...completed...")` in the diff, scan backwards to find all `bool`-returning async calls in the same block. Verify each outcome variable is included in a combined guard before the success log. +- **Fix**: Accumulate outcomes and gate the success log: + ```csharp + var aOk = await DoA(...); + if (!aOk) logger.LogWarning("A failed."); + var bOk = await DoB(...); + if (!bOk) logger.LogWarning("B failed."); + + if (!aOk || !bOk) + { + logger.LogError("Step completed with errors. One or more operations failed and follow-up action is required."); + throw new InvalidOperationException("Step did not complete successfully for all operations."); + } + logger.LogInformation("Step completed successfully."); + ``` +- **Real example** (`CreateInstanceCommand.cs`): Three separate `CreateOrUpdateOauth2PermissionGrantAsync` calls (MCP scopes, Bot API, Observability API) each had their own `LogWarning` on failure, but a single `LogInformation("Admin consent granted ... completed successfully")` was always emitted at the end. Fixed by computing `adminConsentGrantOk = mcpGrantOk && botApiGrantOk && observabilityApiGrantOk` and throwing if false. + +### 24. Expensive Unconditional Startup Code Before Command Dispatch + +An HTTP call, token acquisition, subprocess spawn, or other expensive/network-dependent operation runs unconditionally in startup — before `parser.InvokeAsync(args)` and before the user's chosen command is even parsed. This adds latency to every invocation (including `--help`, `--version`, and offline/CI scenarios) and can fail in environments without network access even when the command doesn't require it. + +- **Pattern to catch**: + - Any `await SomeService.NetworkCallAsync(...)` in `Program.cs` (or equivalent startup file) between `services.BuildServiceProvider()` and `parser.InvokeAsync(args)` + - Calls to `configService.TryResolveXxx(graphApiService)`, `graphApiService.AnyMethodAsync(...)`, or `AzCliHelper.*` that are NOT inside a command handler lambda + - The call is not guarded by a check of whether the command actually needs the result +- **Severity**: `medium` — noticeable latency on every invocation; breaks offline/CI scenarios; especially bad for interactive developer workflows where `a365 --help` should be instant +- **Fix**: Guard with a check of the args array to skip for informational invocations, or move the call inside the command handlers that actually need it: + ```csharp + // Skip for help, version, and empty invocations — must work offline + var isHelpOrVersion = args.Length == 0 || args.Any(a => a is "--help" or "-h" or "--version"); + if (!isHelpOrVersion) + { + try { await configService.TryResolveClientAppIdAsync(graphApiService); } + catch (Exception ex) { logger.LogDebug(ex, "Pre-resolution skipped: {Message}", ex.Message); } + } + ``` + Alternatively, move the call into a `System.CommandLine` middleware so it runs lazily only when a command handler needs it. +- **Real example** (`Program.cs`): `TryResolveClientAppIdAsync` was called unconditionally before `parser.InvokeAsync(args)`, causing a Graph API call + az token acquisition on every invocation including `a365 --help`. Fixed by guarding with `isHelpOrVersion`. + +### 25. Validation Rule Change in Model Not Mirrored in Service-Layer Validator + +When a required-field check is added, removed, or relaxed in a model's `Validate()` method, the same change is almost always needed in the service-level `ValidateAsync()` method — and vice versa. Failing to update both is the root cause of "fixed in one place but still broken in the other" bugs. + +- **Pattern to catch**: + - A diff removes (or adds) a `ValidateRequired(...)` call, or an `if (string.IsNullOrWhiteSpace(...))` guard, inside any `Validate()` method on a model class + - The diff does NOT also touch the service-level validator (`ConfigService.ValidateAsync`, or any method named `ValidateAsync` that takes the same model type) +- **Severity**: `high` — the fix is incomplete; the rule will still fire (or fail to fire) via the other path +- **Check**: For every model-level validation change in the diff, run `Grep` for the same field name + `"is required"` or `ValidateRequired` in `ConfigService.cs`. If the service-level validator has the same rule and the diff doesn't touch it, flag it. +- **Fix**: Apply the same change in both validators, or — better — consolidate so `ConfigService.ValidateAsync` calls `config.Validate()` for required-field rules and only adds format checks on top: + ```csharp + // ConfigService.ValidateAsync — required-field rules delegated to the model + var errors = new List(config.Validate()); + // Format-only checks follow... + if (!string.IsNullOrWhiteSpace(config.TenantId)) + ValidateGuid(config.TenantId, nameof(config.TenantId), errors); + ``` +- **Real example**: Removing `"messagingEndpoint is required when needDeployment is 'no'."` from `Agent365Config.Validate()` without removing the parallel `ValidateRequired(config.MessagingEndpoint, ...)` call in `ConfigService.ValidateAsync`. The fix appeared in `Agent365ConfigTests.cs` and `Agent365Config.cs` but not in `ConfigService.cs`, so `a365 cleanup` still failed with `MessagingEndpoint is required` on bootstrap-path projects. + ## Example Invocation When you receive a request like "Review PR #253", you should: diff --git a/.claude/skills/review-staged/README.md b/.claude/skills/review-staged/README.md index b57e66e9..a53ecca8 100644 --- a/.claude/skills/review-staged/README.md +++ b/.claude/skills/review-staged/README.md @@ -56,6 +56,7 @@ The skill analyzes: - **Security**: Secrets, input validation, error handling - **Standards**: Coding conventions, file organization - **Context**: CLI vs GitHub Actions (different standards apply) +- **Full file content**: Every staged file is read in full — not just the changed lines. This catches issues in unchanged sections such as duplicate hardcoded values, parallel code structures that should use a shared helper, or dead code that the diff didn't touch. ## Review Severity Levels diff --git a/.claude/skills/review-staged/SKILL.md b/.claude/skills/review-staged/SKILL.md index 9ebc9e99..d7698dc7 100644 --- a/.claude/skills/review-staged/SKILL.md +++ b/.claude/skills/review-staged/SKILL.md @@ -109,9 +109,15 @@ The skill uses **Claude Code directly** for semantic code analysis (same as revi 2. Claude Code reads `.github/copilot-instructions.md` for coding standards 3. Claude Code gets staged files: `git diff --staged --name-only` 4. Claude Code gets staged changes: `git diff --staged` -5. Claude Code performs semantic analysis using its own capabilities -6. Claude Code identifies specific issues with line numbers and code references -7. **Claude Code runs the full test suite with per-test timing:** +5. **Claude Code reads the complete current content of every staged file** (not just diff lines) to enable full-file semantic analysis. This is critical for catching issues that exist in unchanged sections of modified files, such as: + - Duplicate hardcoded constants or magic values that already exist elsewhere + - Parallel code structures that should be consolidated (e.g., a method building the same spec list as a shared helper) + - Unused or dead code that was already there but not touched by the diff + - Missing calls to shared helpers — where the diff adds a new use but existing code still has the old duplicate pattern + For each file path returned in step 3, Claude Code must `Read` the full file before performing analysis. +6. Claude Code performs semantic analysis using its own capabilities +7. Claude Code identifies specific issues with line numbers and code references +8. **Claude Code runs the full test suite with per-test timing:** ```bash cd src && dotnet test tests.proj --configuration Release --logger "console;verbosity=normal" 2>&1 ``` diff --git a/.gitignore b/.gitignore index 02d1b05a..e769f62b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ # Internal working documents docs/plans/ +docs/Permissions-Review.md +docs/Testing.md +scripts/skills/ + +# IDE launch profiles (developer-specific) +**/Properties/launchSettings.json ## A streamlined .gitignore for modern .NET projects ## including temporary files, build results, and diff --git a/CHANGELOG.md b/CHANGELOG.md index acbb7f06..685015d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,9 @@ a365 setup admin --config-dir "" - `a365 cleanup azure --dry-run` — preview resources that would be deleted without making any changes or requiring Azure authentication - `AppServiceAuthRequirementCheck` — validates App Service deployment token before `a365 deploy` begins, catching revoked grants (AADSTS50173) early - `a365 setup admin` — new command for Global Administrators to complete tenant-wide AllPrincipals OAuth2 permission grants after `a365 setup all` has been run by an Agent ID Admin +- `setup all --agent-name ` — config-free non-DW setup. No `a365.config.json` required. TenantId is auto-detected from `az account show`; ClientAppId resolved by finding an Entra app registration named `"Agent 365 CLI"` in the tenant. +- `setup all --tenant-id ` — override tenant auto-detection when using `--agent-name`. +- `cleanup --agent-name ` — config-free cleanup. No `a365.config.json` required. Loads resource IDs from the global generated config written by bootstrap setup. Tenant ID is auto-detected from `az account show` or overridden with `--tenant-id`. - MCP V1/V2 migration support — `a365 setup permissions mcp` and `a365 setup blueprint` now handle mixed manifests containing both V1 (`McpServers.*.All` / ATG audience) and V2 (`Tools.ListInvoke.All` / per-server audience) entries; scopes are written additively to the blueprint so agents on either SDK version continue to work - `--remove-legacy-scopes` flag for `a365 setup permissions mcp` — removes shared ATG audience scopes from the blueprint once V2 SDK is confirmed live across all agents - `a365 develop get-token` now acquires one token per audience when using manifest-based scope resolution — V2 entries receive a token scoped to their specific server AppId, V1 entries continue to use the shared ATG AppId @@ -44,6 +47,9 @@ a365 setup admin --config-dir "" - `a365 setup permissions mcp --remove-legacy-scopes --dry-run` now shows both what would be removed (shared ATG audience entries) and what would remain after removal, instead of only showing what would be configured ### Changed +- `setup all --dry-run` output is now column-aligned for readability +- `setup infrastructure` now defaults `deploymentProjectPath` to the current directory when not specified in config +- `setup all` now defaults to the non-AI Teammate (blueprint) flow. Use `--aiteammate true` to run the Digital Worker (AI Teammate) setup flow. - `a365 setup blueprint` now sets `managerApplications` on the blueprint application to enable platform manageability. After May 1, blueprints without `managerApplications` will no longer be accepted, and must be recreated (delete and re-run `a365 setup blueprint`) or manually patched via Graph API to include this value. - `New-Agent365ToolsServicePrincipalProdPublic.ps1` updated to support MCP V1 and V2 provisioning — adds `-Mode` (`V1`/`V2`/`All`, default `All`), `-ManifestPath` (auto-extracts V2 per-server AppIds from `ToolingManifest.json`), and `-V2AppIds` (explicit list) parameters; script is now idempotent across all AppIds (re-run safe) and covers the migration period where V1 and V2 servers coexist in the same tenant - `a365 publish` updates manifest IDs, creates `manifest.zip`, and prints concise upload instructions for Microsoft 365 Admin Center (Agents > All agents > Upload custom agent). Interactive prompts only occur in interactive terminals; redirect stdin to suppress them in scripts. @@ -64,6 +70,7 @@ a365 setup admin --config-dir "" - macOS/Linux: device code fallback when browser authentication is unavailable (#309) - Linux: MSAL fallback when PowerShell `Connect-MgGraph` fails in non-TTY environments (#309) - Admin consent polling no longer times out after 180s — blueprint service principal now resolved with correct MSAL token (#309) +- `a365 cleanup --agent-name` no longer stalls — interactive browser auth failure in embedded terminals now automatically falls back to device code flow (same as Conditional Access fallback) - `ConfigFileNotFoundException` now derives from `FileNotFoundException` so existing catch sites continue to work (#309) - `a365 develop list-available` no longer displays `Required Scope: null` for servers that return a `"null"` string scope from the V2 catalog endpoint - `a365 develop add-mcp-servers` no longer writes the literal string `"null"` as a scope value in `ToolingManifest.json` when the V2 catalog returns `"scope": "null"` — the field is omitted, allowing correct fallback to name-based scope mapping diff --git a/docs/agent365-guided-setup/README.md b/docs/agent365-guided-setup/README.md new file mode 100644 index 00000000..1a62dcd7 --- /dev/null +++ b/docs/agent365-guided-setup/README.md @@ -0,0 +1,48 @@ +# Agent 365 Guided Setup — Index + +> **If you are a coding agent (Claude Code, GitHub Copilot, Devin, or similar):** +> Read this file first, identify the task below, then **immediately open and follow ONLY the linked instruction file**. Open **one file only** — do NOT read both instruction files. Do NOT explore the codebase, fetch web pages, or run commands until you have read the instruction file. The instruction file is your complete guide. + +--- + +## Which file do I need? + +### "Make my agent A365 compliant" / "Set up my agent for Agent 365" / "Run agent setup" + +**→ Open and follow: [a365-setup-instructions.md](a365-setup-instructions.md)** + +Use this file when the user asks to: +- Make an agent A365 compliant +- Set up, provision, or configure an agent for Agent 365 +- Run `a365 setup` or provision Azure / Entra resources +- Deploy an agent application + +### "Add observability to my agent" / "Set up logging / telemetry / tracing" + +**→ Open and follow: [a365-observability-instructions.md](a365-observability-instructions.md)** + +Use this file when the user asks to: +- Add observability, telemetry, logging, or tracing to their agent +- Integrate OpenTelemetry or the Agent 365 observability stack +- Monitor agent activity or usage + +--- + +## If the intent is unclear + +If the user's request matches more than one category, or you are unsure, ask: + +> "Are you looking to (1) set up your agent for Agent 365 compliance, or (2) add observability/telemetry to an existing agent?" + +Wait for the answer, then open the appropriate file above. + +--- + +## What NOT to do + +- Do NOT open both instruction files. Open the one file that matches the task and follow it exclusively. +- Do NOT treat "make my agent A365 compliant" as including observability. Setup and observability are separate tasks with separate instruction files. +- Do NOT glob, search, or read files in the broader repository before reading the instruction file. +- Do NOT fetch external URLs or documentation before reading the instruction file. +- Do NOT ask clarifying questions beyond the one above if the intent is clear. +- Do NOT start writing code or running commands until the instruction file tells you to. diff --git a/docs/agent365-guided-setup/a365-observability-instructions.md b/docs/agent365-guided-setup/a365-observability-instructions.md index d842044a..69519d3e 100644 --- a/docs/agent365-guided-setup/a365-observability-instructions.md +++ b/docs/agent365-guided-setup/a365-observability-instructions.md @@ -1,5 +1,9 @@ # Add Agent 365 Observability +> **SCOPE — THIS FILE ONLY:** This file covers adding the Agent 365 observability SDK and tracing instrumentation to agent code. It does **NOT** cover provisioning, deployment, or CLI setup. +> +> This file is used in two ways: (1) automatically, as the final step of `a365-setup-instructions.md` when the selected capabilities include Observability; (2) directly, when the user explicitly asks to add observability, telemetry, logging, or tracing. If the user asked to start from scratch (e.g. "make my agent A365 compliant"), open `a365-setup-instructions.md` first. + Add Agent 365 observability to your agent at any point after `a365 setup all` has completed. > **Implementation reference:** [Agent observability — Microsoft Learn](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/observability) @@ -14,20 +18,216 @@ Ask your coding agent (Claude Code, GitHub Copilot, or similar): > "Using #file:a365-observability-instructions.md, add observability to this project" -The agent will follow the MS Learn reference above to: -1. Install the observability SDK packages for your project type (.NET, Python, or Node.js) -2. Register the exporter and tracing in startup code -3. Wire up the token resolver in the agent's turn handler -4. Add the exporter configuration setting (`EnableAgent365Exporter` / `ENABLE_A365_OBSERVABILITY_EXPORTER`) and leave it disabled by default +> **Do NOT search NuGet package caches, inspect package XML files, browse MS Learn, or probe installed package versions. All required code is provided verbatim below — use it as-is.** + +1. Install the observability SDK packages: + - **.NET** — run these two commands in the project directory: + ``` + dotnet add package Microsoft.Agents.A365.Observability.Runtime --prerelease + dotnet add package Microsoft.Agents.A365.Observability.Hosting --prerelease + ``` + - Python / Node.js: see the MS Learn reference for current package names + +### .NET helper files — scaffold before step 2 (agentType = 3 only) + +> **agentType = 3 only.** Skip this section for `agentType = 1` (Entra app ID agents) — those use the standard agentic token flow and do not need these files. +> +> These two files bridge gaps in the current SDK release and will be incorporated into `Microsoft.Agents.A365.Observability.Hosting` in a future version. Create them in an `Observability/` subfolder at the root of your project, replacing `` with the project's root namespace. + +**`Observability/ObservabilityServiceExtensions.cs`** — registers the S2S token cache, background token service, and injectable `Agent365ObservabilityContext`: + +```csharp +using Microsoft.Agents.A365.Observability.Hosting; +using Microsoft.Agents.A365.Observability.Runtime.Tracing.Contracts; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ; + +// Injectable singleton wrapping AgentDetails for single-tenant agents. +// Pass ctx.AgentDetails to InvokeAgentScope.Start() for span attributes. +public sealed class Agent365ObservabilityContext +{ + public AgentDetails AgentDetails { get; } + internal Agent365ObservabilityContext(AgentDetails d) => AgentDetails = d; +} + +public static class ObservabilityServiceExtensions +{ + // Registers S2S token cache + exporter, ObservabilityTokenService, and Agent365ObservabilityContext. + // Config is written by `a365 setup all` under the Agent365Observability section. + public static IServiceCollection AddAgent365Observability( + this IServiceCollection services, + string? clusterCategory = "production") + { + services.AddServiceTracingExporter(clusterCategory); + services.AddHostedService(); + services.AddSingleton(sp => + { + var obs = sp.GetRequiredService().GetSection("Agent365Observability"); + var agentDetails = new AgentDetails( + agentId: obs["AgentId"], + agentName: obs["AgentName"], + agentDescription: obs["AgentDescription"], + agentBlueprintId: obs["AgentBlueprintId"], + tenantId: obs["TenantId"] + ?? throw new InvalidOperationException("Agent365Observability:TenantId is required.")); + return new Agent365ObservabilityContext(agentDetails); + }); + return services; + } +} +``` + +**`Observability/ObservabilityTokenService.cs`** — background service that acquires a Power Platform token via a 3-hop FMI chain and refreshes it every 50 minutes: + +```csharp +using Azure.Core; +using Azure.Identity; +using Microsoft.Agents.A365.Observability.Hosting.Caching; +using Microsoft.Identity.Client; + +namespace ; + +// Acquires a Power Platform token for A365 observability via a 3-hop FMI chain. +// Hop 1+2: Blueprint authenticates (MSI in prod, client secret locally) → +// gets T1 via .WithFmiPath(agentId) to Agent Identity. +// Hop 3: Agent Identity uses T1 as assertion → Power Platform token. +// (ServiceIdentity type — AADSTS82001 does not apply.) +internal sealed class ObservabilityTokenService : BackgroundService +{ + private static readonly string[] FmiScopes = ["api://AzureADTokenExchange/.default"]; + private static readonly string[] PowerPlatformScopes = ["https://api.powerplatform.com/.default"]; + private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(50); + + private readonly IExporterTokenCache _tokenCache; + private readonly ILogger _logger; + private readonly string _blueprintClientId, _blueprintClientSecret, _tenantId, _agentId; + + public ObservabilityTokenService( + IExporterTokenCache tokenCache, + ILogger logger, + IConfiguration configuration) + { + _tokenCache = tokenCache; + _logger = logger; + var obs = configuration.GetSection("Agent365Observability"); + _tenantId = obs["TenantId"] ?? throw new InvalidOperationException("Agent365Observability:TenantId is required."); + _agentId = obs["AgentId"] ?? throw new InvalidOperationException("Agent365Observability:AgentId is required."); + _blueprintClientId = obs["ClientId"] ?? throw new InvalidOperationException("Agent365Observability:ClientId is required."); + // ClientSecret is required at construction time even in production: + // MSI is tried first; the secret is only used as a local-dev fallback. + // Ensure ClientSecret is present in all environments (can be a placeholder in prod if MSI is guaranteed). + _blueprintClientSecret = obs["ClientSecret"] ?? throw new InvalidOperationException("Agent365Observability:ClientSecret is required."); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("ObservabilityTokenService started."); + while (!stoppingToken.IsCancellationRequested) + { + try { await AcquireAndRegisterTokenAsync(stoppingToken); } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { _logger.LogWarning(ex, "Failed to acquire observability token; will retry in {Interval}.", RefreshInterval); } + try { await Task.Delay(RefreshInterval, stoppingToken); } + catch (OperationCanceledException) { break; } + } + _logger.LogInformation("ObservabilityTokenService stopped."); + } + + private async Task AcquireAndRegisterTokenAsync(CancellationToken ct) + { + string t1Token; + string authority = $"https://login.microsoftonline.com/{_tenantId}"; -After completing the above steps, the agent **must** ask the user: + // Hop 1+2: Blueprint → T1 via FMI path (MSI in prod, client secret locally) + try + { + // ManagedIdentityCredential.GetTokenAsync uses a resource URI (no /.default suffix). + // FmiScopes uses /.default format — correct for MSAL AcquireTokenForClient. + // These two forms are intentionally different; do not "fix" them to match. + var assertion = await new ManagedIdentityCredential() + .GetTokenAsync(new TokenRequestContext(["api://AzureADTokenExchange"]), ct); + t1Token = (await ConfidentialClientApplicationBuilder + .Create(_blueprintClientId) + .WithClientAssertion((AssertionRequestOptions _) => Task.FromResult(assertion.Token)) + .WithAuthority(new Uri(authority)).Build() + .AcquireTokenForClient(FmiScopes).WithFmiPath(_agentId) + .ExecuteAsync(ct)).AccessToken; + } + catch (AuthenticationFailedException) + { + // MSI unavailable — fall back to client secret (local dev) + t1Token = (await ConfidentialClientApplicationBuilder + .Create(_blueprintClientId) + .WithClientSecret(_blueprintClientSecret) + .WithAuthority(new Uri(authority)).Build() + .AcquireTokenForClient(FmiScopes).WithFmiPath(_agentId) + .ExecuteAsync(ct)).AccessToken; + } -> "Setup is complete. Would you like me to scan your code and add instrumentation automatically? I'll find LLM calls, tool dispatches, agent-to-agent calls, and output operations and wrap each with the appropriate tracing scope." + // Hop 3: Agent Identity uses T1 → Power Platform token + var ppResult = await ConfidentialClientApplicationBuilder + .Create(_agentId) + .WithClientAssertion((AssertionRequestOptions _) => Task.FromResult(t1Token)) + .WithAuthority(new Uri(authority)).Build() + .AcquireTokenForClient(PowerPlatformScopes) + .ExecuteAsync(ct); -- If **yes**: scan all agent source files, identify operations matching the scope types in Task B, present a summary of planned changes, confirm with the user, then apply — adding the correct scope wrapper and required usings to each. + _tokenCache.RegisterObservability(_agentId, _tenantId, ppResult.AccessToken, PowerPlatformScopes); + _logger.LogInformation("Observability token registered for agent {AgentId}.", _agentId); + } +} +``` + +2. **Register the exporter and tracing in startup code** + - .NET (`agentType = 3`): call `builder.Services.AddAgent365Observability()` (using the extension from the helper files above), then `builder.AddA365Tracing()` + - .NET (`agentType = 1`): call `builder.Services.AddAgenticTracingExporter()`, then `builder.AddA365Tracing()` + - Python / Node.js: see the MS Learn reference +3. **Wire up the token resolver in your agent** + - .NET (`agentType = 3`): inject `Agent365ObservabilityContext` into your agent class and any background services; pass `ctx.AgentDetails` directly to `InvokeAgentScope.Start()` — no `RegisterObservability` call needed + - .NET (`agentType = 1`): call `_agentTokenCache.RegisterObservability(agentId, tenantId, new AgenticTokenStruct(...), EnvironmentUtils.GetObservabilityAuthenticationScope())` inside your turn handler + - Python / Node.js: see the MS Learn reference +4. Add the exporter configuration setting (`EnableAgent365Exporter` / `ENABLE_A365_OBSERVABILITY_EXPORTER`) **enabled by default** in the main config, and **disabled in the development/local override** (e.g. `appsettings.Development.json` for .NET, `.env` for Node.js/Python) so that `dotnet run` / local dev stays console-only until the agent is reachable from the platform + + > **To verify exporter connectivity from Visual Studio:** temporarily set `"EnableAgent365Exporter": true` and add `"Microsoft.Agents.A365.Observability": "Debug"` and `"OpenTelemetry": "Debug"` to the `LogLevel` section of `appsettings.Development.json`, then revert when done. + +> **REQUIRED — do not skip this step.** +> After completing steps 1–4 above, you **must** say to the user, verbatim: +> +> "--- +> **Observability SDK is wired up.** Would you like me to scan your code and add instrumentation automatically? I'll find LLM calls, tool dispatches, agent-to-agent calls, and output operations and wrap each with the appropriate tracing scope. +> +> Reply **yes** to add instrumentation, or **no** to skip (you can add it later). +> ---" + +- If **yes**: scan all agent source files, identify operations matching the scope types in Task B, present a summary of planned changes, confirm with the user, then apply — adding the correct scope wrapper and required usings to each. **Follow the hierarchy rule in Task B:** every instrumented block must have `InvokeAgentScope` as its outermost scope; `InferenceScope`, `ExecuteToolScope`, and `OutputScope` are child scopes that go inside it. - If **no**: skip — instrumentation can be added later via Task B. -After setup, set `EnableAgent365Exporter` to `true` in `appsettings.json` (or `ENABLE_A365_OBSERVABILITY_EXPORTER=true` in `.env`) to start exporting traces. +> **Note — recording response data:** Auto-instrumentation adds scope wrappers only. To attach the actual response text to a span, call the appropriate record method manually after you have the result: +> - `invokeAgentScope.RecordResponse(responseText)` — adds the agent's final reply to the `invoke_agent` span +> - `inferenceScope.RecordOutputMessages(...)` / `inferenceScope.RecordInputMessages(...)` — attaches LLM output/input messages to the `Chat` span +> +> These are one-liners and are best added by hand once you know which variable holds the response. + +### Task A completion — final summary + +> **REQUIRED.** After Task A is complete (SDK wired up, instrumentation applied or skipped), output a single combined summary in this format and nothing else: +> +> **Agent 365 setup complete.** +> +> **Provisioned resources** _(from `a365.generated.config.json` or the setup CLI output shown earlier in this session):_ +> - Blueprint: `` _(agentType=1: N/A — uses Entra app ID instead)_ +> - Agent identity: `` +> - Agent registration: `` +> - Config written to: `appsettings.json` +> +> **Observability** _(list each file and scope added, or "No instrumentation added" if skipped):_ +> - `` — `` around `` +> - ... +> - Tracing exports to the A365 service by default. To disable locally: set `"EnableAgent365Exporter": false` in `appsettings.Development.json` (or the equivalent local env override for your platform) + +Do NOT add commentary, next-step suggestions, or further output after this summary. --- @@ -43,13 +243,34 @@ The agent will: 3. Present its interpretation and ask for confirmation before making any changes 4. Wrap the code block with the correct Agent365 tracing scope +### Scope hierarchy — read this before instrumenting + +Scopes are **hierarchical, not peer**. `InvokeAgentScope` is the root; the others are children that go inside it. The `Agent365Exporter` only exports `InvokeAgentScope` spans — child scopes opened without a parent `InvokeAgentScope` are silently dropped and never reach the observability service. + +``` +InvokeAgentScope ← root — always required; one per agent turn or autonomous operation + ├── InferenceScope ← child — wrap each LLM call inside the turn + ├── ExecuteToolScope ← child — wrap each tool dispatch inside the turn + └── OutputScope ← child — wrap the final reply inside the turn +``` + +For simple agents with no nested LLM calls or tool dispatches, `InvokeAgentScope` alone is sufficient — do not add child scopes just to have them. + ### Supported scope types (auto-detected from code) -| What the code does | Scope applied | -|-------------------|---------------| -| Calls an LLM/model API (`gpt-4o`, `claude-3`, etc.) | `InferenceScope` | -| Dispatches a tool or plugin function | `ExecuteToolScope` | -| Calls another agent (A2A) | `InvokeAgentScope` | -| Sends final response back to user | `OutputScope` | +| What the code does | Scope | Role | +|-------------------|-------|------| +| Handles a user message, a background/autonomous task, or an A2A call — any agent "turn" | `InvokeAgentScope` | **Root** — required outermost scope for every instrumented block | +| Calls an LLM/model API (`gpt-4o`, `claude-3`, etc.) | `InferenceScope` | Child — nest inside an open `InvokeAgentScope` | +| Dispatches a tool or plugin function | `ExecuteToolScope` | Child — nest inside an open `InvokeAgentScope` | +| Sends final response back to user | `OutputScope` | Child — nest inside an open `InvokeAgentScope` | + +> **CRITICAL:** Do NOT open `InferenceScope`, `ExecuteToolScope`, or `OutputScope` as standalone top-level scopes. They will compile and run without error but produce orphaned spans that the exporter never picks up. + +**For .NET agent turn handlers**, chain `.FromTurnContext(tc)` on `InvokeAgentScope` to propagate conversation baggage (tenantId, conversationId, channelId) into the span: +```csharp +using var scope = InvokeAgentScope.Start(new Request(text), new InvokeAgentScopeDetails(), agentDetails) + .FromTurnContext(tc); +``` For Python and Node.js, equivalent OpenTelemetry spans are used with the same Agent365 attribute names. See the [MS Learn reference](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/observability) for attribute names and patterns. diff --git a/docs/agent365-guided-setup/a365-setup-instructions.md b/docs/agent365-guided-setup/a365-setup-instructions.md index 21aea624..508ec062 100644 --- a/docs/agent365-guided-setup/a365-setup-instructions.md +++ b/docs/agent365-guided-setup/a365-setup-instructions.md @@ -1,24 +1,65 @@ # Agent 365 CLI Setup Instructions for AI Agents +> **SCOPE — THIS FILE ONLY:** This file covers provisioning and deploying an agent using the Agent 365 CLI (`a365 setup`, `a365 publish`, `a365 deploy`). It does **NOT** cover adding observability, telemetry, or SDK integrations to the agent's code. If the user asked to add observability, close this file and open `a365-observability-instructions.md` instead. + --- -> **YOUR FIRST AND ONLY ACTION RIGHT NOW:** Call `manage_todo_list` (or equivalent) to create the 5 todos listed below. Then mark Todo 1 in-progress and jump to Step 1. **Do NOT read further. Do NOT run any commands. Do NOT gather values. Do NOT ask questions.** +> **YOUR FIRST AND ONLY ACTION RIGHT NOW:** Ask the user the two path-determination questions below. Do NOT create todos, run commands, or read further until the user has answered both questions. After both answers are received, create all todos for the determined path and mark Todo 1 in-progress. + +**RULE 1 — ASK TWO QUESTIONS FIRST, THEN CREATE ALL TODOS.** + +Before creating any todos or running any commands, ask the user these two questions (one at a time, wait for each response): + +**Question 1: Which of the following best describes your agent?** + +1. M365 custom engine agent +2. All other agents + +Wait for the answer. Store as `agentType`: +- If **1 (M365 custom engine agent)**: `agentType = 1` +- If **2 (All other agents)**: `agentType = 2` + +**Question 2: What capabilities do you want to enable?** + +Present only the options that apply to the user's `agentType`: + +- **If `agentType = 1`** (M365 custom engine agent — Discoverability is already enabled): + 1. Observability + 2. Observability and Work IQ + 3. AI Teammate +- **If `agentType = 2`** (All other agents): + 1. Discoverability + 2. Discoverability and Observability + 3. Discoverability, Observability, and Work IQ + 4. AI Teammate + +Wait for the answer. Store as `capabilities`. + +> **Note:** The setup automatically includes all prerequisite capabilities for your selection. -**RULE 1 — CREATE EXACTLY 5 TODOS: +After both questions are answered, set `isAITeammate = true` if `capabilities = AI Teammate`, else `isAITeammate = false`. Then create all todos for the path and mark Todo 1 in-progress: +**AI Teammate path** — `isAITeammate = true` (5 todos total): - Todo 1: `Step 1: Verify and Install/Update the Agent 365 CLI` - Todo 2: `Step 2: Ensure Prerequisites and Environment Configuration` - Todo 3: `Step 3: Configure the Agent 365 CLI (Initialize Configuration)` - Todo 4: `Step 4: Run Agent 365 Setup to Provision Prerequisites` - Todo 5: `Step 5: Publish and Deploy the Agent Application` -**RULE 2 — ALWAYS BEGIN FROM STEP 1.** No step is optional. Even if the CLI appears installed or Azure appears logged in, you MUST run the validation commands in each step. +**Standard path** — `isAITeammate = false` (3 todos total): +- Todo 1: `Step 1: Verify and Install/Update the Agent 365 CLI` +- Todo 2: `Step 2: Ensure Prerequisites and Environment Configuration` +- Todo 3: `Step 4: Run Agent 365 Setup to Provision Prerequisites` + +**RULE 2 — ALWAYS BEGIN FROM STEP 1.** No step is optional within your path. Even if the CLI appears installed or Azure appears logged in, you MUST run the validation commands in each step. Step 3 (Configure) is only required on the AI Teammate path (`isAITeammate = true`) — it is skipped entirely on all other paths. -**RULE 3 — SUB-SECTIONS ARE NOT SEPARATE TODOS.** Each `## Step` has internal sub-sections — these are tasks WITHIN that step, NOT separate todos. Exactly 5 todos total. +**RULE 3 — SUB-SECTIONS ARE NOT SEPARATE TODOS.** Each `## Step` has internal sub-sections — these are tasks WITHIN that step, NOT separate todos. -**RULE 4 — ONE STEP AT A TIME.** Complete each step fully. Mark its todo in-progress when starting, complete when done. Do NOT run `az account show`, ask about deployment type, or gather Azure values — those belong to Step 3, which comes AFTER Steps 1 and 2. +**RULE 4 — ONE STEP AT A TIME.** Complete each step fully. Mark its todo in-progress when starting, complete when done. Do NOT run `az account show`, ask about deployment type, or gather Azure values — those belong to Steps 3 and 2 respectively. The path determination questions (`agentType`, `capabilities`) were already answered before Step 1. -**RULE 5 — INPUT FIELDS.** In Step 3, present exactly 5 fields (Azure-hosted) or 2 fields (self-hosted). The `clientAppId` is collected in Step 2 — do NOT ask for it again. +**RULE 6 — SILENT EXECUTION.** Work silently. Do NOT narrate what you are about to do, announce step transitions ("Proceeding to Step 2", "CLI installed, moving on"), print todo state, emoji checklists, or step completion summaries. Only speak to the user when you need input, have an error to report, or need confirmation before a destructive action. + +**RULE 5 — INPUT FIELDS.** In Step 3 (AI Teammate path only), present exactly 5 fields (Azure-hosted) or 2 fields (self-hosted). Do NOT ask the user for a client app ID — the CLI resolves it automatically by the well-known app name "Agent 365 CLI". --- @@ -73,46 +114,34 @@ The CLI is under active development, and some commands may have changed in recen The Agent 365 CLI relies on Azure context for deploying resources and may use your Azure credentials. Verify that the Azure CLI (`az`) is installed by running `az --version`. If it's not available, install the [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) for your platform or prompt the user to do so. -If the Azure CLI is installed, ensure that you are logged in to the correct Azure account and tenant. Run `az login` (and `az account set -s ` if you need to select a specific subscription). If you cannot perform an interactive login directly, output a clear instruction for the user to log in (the user may need to follow a device-code login URL if running in a headless environment). The Agent 365 CLI will use this Azure authentication context to create resources. - -### Microsoft Entra ID (Azure AD) roles - -The user account you authenticate with must have sufficient privileges to create the necessary resources. According to documentation, the account needs to be at least an **Agent ID Administrator** or **Agent ID Developer**, and certain commands (like the full environment setup) require **Global Administrator + Azure Contributor** roles. If you attempt an operation without adequate permissions, it will fail. Thus, before proceeding, confirm that the logged-in user has one of the required roles (Global Admin is the safest choice for preview setups). If not, prompt the user to either use an appropriate account or have an admin grant the needed roles. - -### Custom client app validation - -Ask the user: "Please provide the Application (client) ID for your custom Agent 365 client app registration." If they don't have one, see "What to do if validation fails" below. +> **CRITICAL — Complete `az login --allow-no-subscriptions` before any `a365` command.** +> +> The Agent 365 CLI authenticates to Microsoft Graph using **MSAL** with the token acquired by `az login`. If `az login` has not been completed successfully, the CLI will launch an interactive auth prompt (WAM on Windows, browser on Mac/Linux) that **you as a coding agent cannot interact with**. This will block setup indefinitely. +> +> Use `az login --allow-no-subscriptions` — the non-DW setup flow requires no Azure subscription, and plain `az login` will fail for users who have none. +> +> **You must ensure `az login --allow-no-subscriptions` is complete and `az account show` returns a valid account before proceeding past Step 2.** -Once the user provides the ID, replace `` in the command below and paste it into the terminal verbatim. **Use this exact command — do not write your own queries, do not split it, do not run `az ad app show` or `az ad app permission` separately:** +Run the following and verify the output shows a valid account: ```bash -az ad app show --id --query "{appId:appId, displayName:displayName, requiredResourceAccess:requiredResourceAccess}" -o json && az ad app permission list-grants --id --query "[].{resourceDisplayName:resourceDisplayName, scope:scope}" -o table +az account show --query "{user:user.name, tenantId:tenantId}" -o json ``` -From the output of the command above, verify these 7 permissions appear with admin consent. If any are missing or consent is not granted, see "What to do if validation fails" below. +- If this succeeds: the login is active. Continue. +- If this fails or returns no output: **STOP. Tell the user to run `az login --allow-no-subscriptions` in their terminal and complete the login, then confirm back to you.** Do NOT proceed until `az account show` returns a valid account. -Required **delegated** Microsoft Graph permissions (all must have **admin consent granted**): +> **Why this matters:** After a successful `az login --allow-no-subscriptions`, the CLI can acquire Graph tokens **silently** from the cache — no WAM dialog, no browser tab, no device code. Skipping this step is the most common cause of interactive auth prompts that block automated setup. -| Permission | Description | -|------------|-------------| -| `AgentIdentityBlueprint.ReadWrite.All` | Manage Agent 365 Blueprints | -| `AgentIdentityBlueprint.UpdateAuthProperties.All` | Update Blueprint auth properties | -| `AgentIdentityBlueprint.AddRemoveCreds.All` | Add and remove credentials on Agent Identity Blueprints | -| `Application.ReadWrite.All` | Create and manage Azure AD applications | -| `DelegatedPermissionGrant.ReadWrite.All` | Grant delegated permissions | -| `Directory.Read.All` | Read directory data | -| `User.ReadWrite.All` | Create agent users, set usage location, and assign licenses | +### Microsoft Entra ID (Azure AD) roles -If the app does not exist, permissions are missing, or admin consent has not been granted, see "What to do if validation fails" below. +The user account you authenticate with must have sufficient privileges to create the necessary resources. According to documentation, the account needs to be at least an **Agent ID Administrator** or **Agent ID Developer**, and certain commands (like the full environment setup) require **Global Administrator + Azure Contributor** roles. If you attempt an operation without adequate permissions, it will fail. Thus, before proceeding, confirm that the logged-in user has one of the required roles (Global Admin is the safest choice for preview setups). If not, prompt the user to either use an appropriate account or have an admin grant the needed roles. -**If validation fails** (app not found, permissions missing, or no admin consent): +### Custom client app -1. STOP — do not proceed to run any `a365` CLI commands. -2. Inform the user the custom client app registration is missing or incomplete. -3. Direct the user to the official setup guide: register the app, configure as a Public client with redirect URI `http://localhost:8400`, add all seven permissions above, and have a Global Admin grant admin consent. -4. Wait for the user to confirm the app is properly configured, then re-run the same validation command above. +The Agent 365 CLI resolves the client app automatically by the well-known display name **"Agent 365 CLI"** registered in the tenant. Do NOT ask the user for a client app ID. -Save the `clientAppId` value — it will be used automatically in Step 3 (do NOT ask the user for it again). +The CLI will validate permissions and prompt for consent at runtime if anything is missing. If the CLI reports that the "Agent 365 CLI" app cannot be found in the tenant, inform the user that an admin must register an Entra app with that exact display name and grant admin consent for the required permissions, then retry. ### Validate language-specific prerequisites (REQUIRED) @@ -183,21 +212,28 @@ pip --version ### Step 2 completion -> **BEFORE MOVING ON:** Mark Todo 2 (Step 2) as **completed** now. Summarize to the user what was validated. Then mark Todo 3 (Step 3) as **in-progress**. Only then proceed to Step 3 below. -> -> **VERIFY YOUR TODO STATE:** At this point your todos MUST look like this: -> - Todo 1: **completed** | Todo 2: **completed** | Todo 3: **in-progress** | Todo 4: not-started | Todo 5: not-started +> **BEFORE MOVING ON:** Mark Todo 2 (Step 2) as **completed** now. Summarize to the user what was validated. Then proceed based on your path: +> - **AI Teammate path** (`isAITeammate = true`): Mark Todo 3 in-progress and proceed to Step 3. +> - **All other paths** (`isAITeammate = false`): Skip Step 3 entirely. Mark Todo 3 in-progress and jump directly to Step 4. > -> If your todo list does not exist or does not look like the above, STOP — go back to "BEFORE YOU BEGIN" and start over. +> **VERIFY YOUR TODO STATE:** +> - AI Teammate path: Todo 1: **completed** | Todo 2: **completed** | Todo 3: **in-progress** | Todo 4: not-started | Todo 5: not-started +> - All other paths: Todo 1: **completed** | Todo 2: **completed** | Todo 3: **in-progress** --- ## Step 3: Configure the Agent 365 CLI (Initialize Configuration) +> **AI TEAMMATE PATH ONLY** (`capabilities = AI Teammate`, `isAITeammate = true`). +> +> If `isAITeammate = false` (Standard path), you should NOT be here. Go back, mark Todo 3 (Step 4) in-progress, and jump directly to Step 4. +> +> If `isAITeammate = true`, continue below. + > **MANDATORY GATE — DO NOT PROCEED WITHOUT VERIFICATION:** > > Before executing ANY part of this step, verify ALL of the following: -> - [ ] You created exactly 5 todos (RULE 1) +> - [ ] You created exactly 5 todos (AI Teammate path — RULE 1) > - [ ] Todo 1 (Step 1) is marked **completed** — CLI was verified/installed > - [ ] Todo 2 (Step 2) is marked **completed** — Azure CLI login confirmed, custom client app validated, build tools verified > - [ ] Todo 3 (Step 3) is marked **in-progress** @@ -288,7 +324,7 @@ Present the following fields in a single prompt: | **Manager Email** | M365 manager email (must be from your tenant) | `{loggedInUser}` | | **App Service Plan** | Azure App Service Plan name | `{existingAppServicePlan}` | -> **Agent Name rules:** Must be **globally unique across all of Azure**. Used to derive the web app URL (`{name}-webapp.azurewebsites.net`), Agent Identity, Blueprint, and User Principal Name. Lowercase letters, numbers, hyphens only. Start with a letter. 3-20 chars recommended. Tip: include your org name. +> **Agent Name rules:** Must be **globally unique across all of Azure**. Used to derive the web app URL (`{name}-webapp.azurewebsites.net`), Agent Identity, Blueprint, and User Principal Name. Letters, numbers, hyphens only; any casing is accepted. Start with a letter. 3-20 chars recommended. Tip: include your org name. > > **Examples** show real values from your subscription. You can reuse existing resources or provide new names — the CLI will create them if they don't exist. > @@ -307,7 +343,7 @@ Present the following fields in a single prompt: | **Agent Name** | Unique name for your agent (see rules below) | `contoso-support-agent` | | **Manager Email** | M365 manager email (must be from your tenant) | `{loggedInUser}` | -> **Agent Name rules:** Must be **globally unique across all of Azure**. Used to derive Agent Identity, Blueprint, and User Principal Name. Lowercase letters, numbers, hyphens only. Start with a letter. 3-20 chars recommended. Tip: include your org name. +> **Agent Name rules:** Must be **globally unique across all of Azure**. Used to derive Agent Identity, Blueprint, and User Principal Name. Letters, numbers, hyphens only; any casing is accepted. Start with a letter. 3-20 chars recommended. Tip: include your org name. After collecting these inputs, proceed to Step 3.3.1 to determine the messaging endpoint. @@ -442,47 +478,142 @@ Once `a365 config init` completes without errors, you have a baseline configurat ## Step 4: Run Agent 365 Setup to Provision Prerequisites -With the CLI configured, the next major step is to set up the cloud resources and Agent 365 blueprint required for your agent. The CLI provides a one-stop command to do this: +### 4.1 — Collect provisioning inputs -### Execute the setup command +**For the Standard path (`isAITeammate = false`):** -Run `a365 setup all`. This single command performs all the necessary setup steps in sequence. Under the hood, it will: +Ask the user two questions (one at a time, wait for each response): -- Create or validate the Azure infrastructure for the agent (Resource Group, App Service Plan, Web App, and enabling a system-assigned Managed Identity on the web app). -- Create the Agent 365 Blueprint in your Microsoft Entra ID (Azure AD). This involves creating an Azure AD application (the "blueprint") that represents the agent's identity and blueprint configuration. The CLI uses Microsoft Graph API for this. -- Configure the blueprint's permissions (for MCP and for the bot/App Service). This likely entails granting certain API permissions or setting up roles so that the agent's identity can function (for example, granting the blueprint the ability to have "inheritable permissions" or other settings, which requires Graph API operations). -- Register the messaging endpoint for the agent's integration (this ties the web application to the Agent 365 service so that Teams and other Microsoft 365 apps can communicate with the agent). +1. **"What agent name should be used for provisioning?"** + - Must be globally unique across Azure + - Letters, numbers, and hyphens only; start with a letter; 3–20 characters recommended + - **No casing restriction** — mixed case is fine. `SunilsAgent1` is a valid name. Pass it to the CLI exactly as the user typed it. + - Example: `contoso-support-agent` + - If the user replies `default`, use `developer` -In summary, "setup all" carries out what used to be multiple sub-commands (`setup infrastructure`, `setup blueprint`, `setup permissions mcp`, `setup permissions bot`, etc.), so running it will perform a comprehensive initial setup. + Store as `agent_name`. Pass it to the CLI verbatim — do NOT normalize or change the casing. -### Monitor the output +2. **"What is the project directory containing your agent code? Reply with a full path, or reply 'current' to use the current working directory."** -This command may take a few minutes as it provisions cloud resources and does Graph API calls. Monitor the console output carefully: + Store as `project_dir`. If the user replies `current`, use the current working directory. -- The CLI will log progress in multiple steps (often numbered like `[0/5]`, `[1/5]`, etc.). Watch for any errors or warnings. Common points of failure include: Azure resource creation issues (quota exceeded, region not available, etc.), or Graph permission issues when creating the blueprint (e.g. insufficient privileges causing a "Forbidden" or "Authorization_RequestDenied" error). -- If the CLI outputs a warning about Azure CLI using 32-bit Python on 64-bit system (on Windows) or similar performance notices, you can note them but they don't block execution — they just suggest installing a 64-bit Azure CLI for better performance. This is not critical for functionality. -- If resource group or app services already exist (maybe from a previous run or a partially completed setup), the CLI will usually detect them and skip creating duplicates, which is fine. +**For the AI Teammate path (`isAITeammate = true`):** -### Important considerations +- `agent_name` is derived from `agentBaseName` collected in Step 3 — do NOT ask again. +- `project_dir` is the `deploymentProjectPath` from the config — do NOT ask again. -- **Quota limits:** If you see an error like "Operation cannot be completed without additional quota" during App Service plan creation, that means the Azure subscription has hit a quota limit (for example, no free capacity for new App Service in that region or SKU). In this case, you might need to change the region or service plan SKU, or have the user request a quota increase. This is an Azure issue, not a CLI bug. Report this clearly to the user and halt, or try choosing a different region if possible (you would need to update the config's `location` and possibly rerun setup). -- **Region support:** If you see errors related to Azure region support (for instance, an error about an Azure resource not available in region), recall that Agent 365 preview might support only certain regions for Bot Service or other components. If that happens, choose a supported region (update your `a365.config.json` with a supported `location` and run `a365 setup all` again). -- **Graph API permission errors:** If there are Graph API permission errors while creating the blueprint (e.g., a "Forbidden" error creating the application or setting permissions), this likely indicates the account running the CLI lacks a required directory role or the custom app's permissions aren't correctly consented. For example, an error containing "Authorization_RequestDenied" or mention of missing `AgentIdentityBlueprint` permissions suggests the custom app might not have those delegated permissions with admin consent. In such a case, stop and resolve the permission issue (see Step 2). You may need to have a Global Admin grant the consent or use an account with the appropriate role. After fixing, you can retry `a365 setup all`. -- **Interactive authentication during setup:** The CLI might attempt to do an interactive login to Azure AD (especially for granting some permissions or acquiring tokens for Graph). If running in a headless environment, this could fail (e.g., you see an error about `InteractiveBrowserCredential` or needing a GUI window). The CLI should ideally use the Azure CLI token, but for certain Graph calls (like `AgentIdentityBlueprint.ReadWrite.All` which might not be covered by Azure CLI's token), it might launch a browser auth. If this happens, see troubleshooting below for how to handle interactive auth in a non-interactive setting. +--- -### Completion of setup +### 4.2 — Dry-run preview (REQUIRED — do not skip) -If `a365 setup all` completes successfully, you should see a confirmation in the output. It typically indicates that the blueprint is created and the messaging endpoint is registered. The CLI might output important information such as: the Agent Blueprint Application ID it created, or any Consent URLs for adding additional permissions. For instance, sometimes after setup, the CLI might provide a URL for admin consent (though if the custom app was properly set up with consent, ideally this isn't needed). If any consent URL or similar is printed, make sure to surface that to the user with an explanation (e.g., "The CLI is asking for admin consent for additional permissions; please open the provided URL in a browser and approve it as a Global Admin, then press Enter to continue."). The CLI may pause until consent is granted in such cases. +> **This is a safety check. You MUST run the dry-run and show the output to the user before applying anything.** -### Note on Idempotency +Run the following command and display the full output to the user: -You can generally re-run `a365 setup all` if something went wrong and you fixed it. The CLI is designed to skip or reuse existing resources, as seen in the logs (e.g., resource group already exists, etc.). So don't hesitate to run it again after addressing an issue. If for some reason you need to start over, the CLI provides a cleanup command (`a365 cleanup`) to remove resources, but use that with caution (it can delete a lot). It's usually not necessary unless you want to wipe everything and retry from scratch. +**Standard path:** +```bash +cd "" && a365 setup all --agent-name --dry-run +``` + +**AI Teammate path:** +```bash +cd "" && a365 setup all --dry-run +``` + +After displaying the full output, ask the user: + +**"Do you want to proceed with the setup shown above? (yes/no)"** + +- If **no** (or anything other than yes/y): Stop. Tell the user "Setup cancelled. Return to Step 4 when ready." Do NOT proceed. +- If **yes**: Proceed to 4.3. + +--- + +### 4.3 — Apply setup + +Run the following command from `project_dir` and stream all output: + +**Standard path:** +```bash +cd "" && a365 setup all --agent-name +``` + +**AI Teammate path:** +```bash +cd "" && a365 setup all +``` + +This single command performs all necessary setup steps in sequence: +- Creates or validates the Azure infrastructure (Resource Group, App Service Plan, Web App, Managed Identity) +- Creates the Agent 365 Blueprint in Microsoft Entra ID +- Configures the blueprint's permissions +- Registers the messaging endpoint + +This command may take several minutes. Monitor output carefully: + +- The CLI logs progress in numbered steps (e.g., `[1/5]`, `[2/5]`). Watch for errors or warnings. +- Performance notices (e.g., 32-bit Azure CLI on 64-bit system) are non-blocking — note them but continue. +- If existing resources are detected from a previous run, the CLI will skip recreating them — this is expected. + +**Important considerations:** + +- **Quota limits:** An error like "Operation cannot be completed without additional quota" means the Azure subscription has hit a capacity limit for that region/SKU. Report this to the user and halt. If possible, update `location` in the config (AI Teammate path) or ask the user for a new region (Standard path) and retry. +- **Region support:** If an Azure resource is not available in the selected region, update the location and retry. Agent 365 preview supports only certain regions. +- **Graph API permission errors:** A "Forbidden" or "Authorization_RequestDenied" error during blueprint creation indicates insufficient directory role or missing admin consent. Stop and resolve the permission issue (refer back to Step 2). After fixing, re-run `a365 setup all`. +- **Interactive authentication — WAM on Windows / browser on Mac/Linux (expected on first run):** + On the first run on a new machine the CLI's own token cache is empty. Even with `az login` done, the CLI may need the user to authenticate once to populate its cache. After that first auth, all subsequent runs are silent. + + **Before you run `a365 setup all`, warn the user:** + + > "The setup command may open a Windows sign-in dialog (WAM) or browser tab to authenticate to Microsoft Graph. Please watch your screen and complete any sign-in prompt that appears — the command will continue automatically once you do." + + **While `a365 setup all` is running, monitor the output:** + + - If you see `"Authenticating via Windows Account Manager..."`: the CLI is waiting for the user to complete a **native Windows dialog** that appeared on their screen. **Do NOT kill the process.** Send the user this message: "A Windows sign-in dialog has appeared on your screen. Please complete it — the setup will continue automatically." Then continue monitoring output and wait for the CLI to resume. + - If you see a browser URL printed (device code flow): the CLI is in device code mode. Share the URL and code with the user, tell them to visit it in a browser and sign in, then wait. + - If the CLI is silent for more than 3 minutes after one of these messages: ask the user whether they completed the dialog/code. If yes, the CLI may have an issue — cancel and re-run `a365 setup all`. If no, remind them to complete it. + + Once the user completes auth once, the token is cached. Subsequent runs will be fully silent. +- **Idempotency:** `a365 setup all` is safe to re-run after fixing an issue. It skips or reuses existing resources. Use `a365 cleanup` only as a last resort. + +--- + +### 4.4 — Show setup output to user + +After `a365 setup all` completes, show the user exactly this — nothing more, nothing less: + +1. **The Setup Summary table** from the CLI output — copy it verbatim. + +2. **If the CLI printed an admin consent action item (Permission Grants):** Show both options exactly as printed by the CLI: + - Option A (Entra portal steps) — verbatim + - Option B (PowerShell script) — verbatim + +3. **Skip the client secret action item entirely.** Do not show it, do not mention it. + +4. After showing the CLI output sections above, output exactly one of these closing lines — choose based on what the CLI reported: + - **If the CLI printed an admin consent action item** (i.e., you showed a PowerShell script in step 2 above): + > "Your agent is provisioned. Have a Global Admin run the PowerShell script above to complete admin consent." + - **If Permission Grants row in the Summary shows `ok`** (no action item was printed): + > "Your agent is provisioned." + +### Step 4 completion + +Mark all todos as completed, then check `capabilities`: + +> **If `capabilities` includes "Observability"** (`agentType = 1` with Observability or Observability and Work IQ, or `agentType = 2` with Discoverability and Observability or Discoverability, Observability, and Work IQ): immediately start following `#file:a365-observability-instructions.md`. The user already selected observability — do NOT ask again, just proceed. +> +> **Otherwise**: say to the user verbatim: +> "Your agent is provisioned. Would you like to add observability so you can trace LLM calls, tool dispatches, and agent-to-agent calls?" +> - If **yes**: follow `#file:a365-observability-instructions.md` to add observability. Do not proceed to Step 5. +> - If **no**: this is the final action — do NOT send any further response, do NOT proceed to Step 5. --- ## Step 5: Publish and Deploy the Agent Application -At this stage, your environment (Azure infrastructure and identity blueprint) is set up. Next, you need to publish the agent and deploy the application code so that the agent is live. +> **AI TEAMMATE PATH ONLY.** This step does not exist on the Standard path. If `isAITeammate = false`, you should not be here. + +At this stage, your agent is set up. You should be able to see your agent in Microsoft Admin Center Agent Registry. Observability will be added after this step completes. ### Review and Update the Manifest File (REQUIRED) @@ -654,6 +785,11 @@ az webapp log tail --name --resource-group If your agent instance isn't working as expected, see the Troubleshooting section below or the [Agent 365 Troubleshooting Guide](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/troubleshooting). +### Step 5 completion + +> **REQUIRED — do not skip this step.** +> AI Teammate includes observability. Immediately start following `#file:a365-observability-instructions.md`. Do NOT ask the user — just proceed. + --- ## Error Handling and Troubleshooting @@ -671,6 +807,22 @@ If any step results in an error, stop and analyze the error message carefully. F - Most `a365` commands are idempotent — safe to re-run after fixing an issue. - Use `a365 cleanup azure` or `a365 cleanup blueprint` only as a last resort to remove created resources. +### Windows Account Manager (WAM) authentication + +**What it is:** On Windows, the Agent 365 CLI uses the Windows Account Manager (WAM) broker instead of a browser for interactive Microsoft Graph authentication. WAM opens a native OS dialog — not a browser tab — so it is invisible to terminal output. + +**What the coding agent sees:** The log line `"Authenticating via Windows Account Manager..."` followed by silence. The CLI is not hung; it is waiting for the user to complete a dialog that appeared on their screen. + +**What to do:** Tell the user: "A Windows sign-in dialog has appeared on your screen. Please complete the authentication to continue the setup." Do not kill the process. Once the user completes the dialog, the CLI resumes automatically. + +**If the dialog doesn't appear or disappears:** Have the user check minimized windows and the taskbar. If no dialog appeared, the token may already be cached (setup continues silently) — wait 10–15 seconds before assuming it's stuck. + +**If running headless (no desktop, e.g. a remote VM without a display):** WAM cannot show a dialog. Workaround: have the user run `az login --allow-no-subscriptions` in an interactive terminal session first. If az CLI has a cached token for the tenant and the correct account, the CLI will use it silently without needing WAM. If `az login` is not an option, the user must run the setup command from a machine with a desktop session. + +**WAM hangs with no dialog and no error (rare):** Kill the process (`Ctrl+C`), have the user run `az login --allow-no-subscriptions --tenant ` to refresh the az CLI credential, then retry `a365 setup all`. + +--- + ### Dev tunnel issues **Dev tunnel CLI not found:** Ensure the installation completed and the binary is on your PATH. On Windows, restart your terminal or add the installation directory manually. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs index a8c4f079..90508bd8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs @@ -35,7 +35,8 @@ public static Command CreateCommand( AgentBlueprintService agentBlueprintService, IConfirmationProvider confirmationProvider, FederatedCredentialService federatedCredentialService, - AzureAuthValidator authValidator) + AzureAuthValidator authValidator, + GraphApiService? graphApiService = null) { var cleanupCommand = new Command("cleanup", "Clean up ALL resources (blueprint, instance, Azure) - use subcommands for granular cleanup"); @@ -47,25 +48,63 @@ public static Command CreateCommand( ArgumentHelpName = "file" }; + var agentNameOption = new Option( + new[] { "--agent-name", "-n" }, + description: "Agent base name used with 'setup all --agent-name'. When provided, no config file is required.\n" + + "Loads resource IDs from generated config in the current directory first, then falls back to the global generated config if available."); + + var tenantIdOption = new Option( + "--tenant-id", + description: "Azure AD tenant ID. Overrides auto-detection from 'az account show'. Use with --agent-name."); + + var yesOption = new Option( + ["--yes", "-y"], + description: "Skip confirmation prompts and proceed automatically"); + var verboseOption = new Option( - new[] { "--verbose", "-v" }, + ["--verbose", "-v"], description: "Enable verbose logging"); cleanupCommand.AddOption(configOption); + cleanupCommand.AddOption(agentNameOption); + cleanupCommand.AddOption(tenantIdOption); + cleanupCommand.AddOption(yesOption); cleanupCommand.AddOption(verboseOption); // Set default handler for 'a365 cleanup' (without subcommand) - cleans up everything - cleanupCommand.SetHandler(async (configFile, verbose) => + cleanupCommand.SetHandler(async (System.CommandLine.Invocation.InvocationContext context) => { + var configFile = context.ParseResult.GetValueForOption(configOption); + var agentName = context.ParseResult.GetValueForOption(agentNameOption); + var tenantIdFlag = context.ParseResult.GetValueForOption(tenantIdOption); + var yes = context.ParseResult.GetValueForOption(yesOption); + _ = context.ParseResult.GetValueForOption(verboseOption); // consumed by Program.cs startup via args + // Generate correlation ID at workflow entry point var correlationId = HttpClientFactory.GenerateCorrelationId(); logger.LogInformation("Starting cleanup (CorrelationId: {CorrelationId})", correlationId); - - await ExecuteAllCleanupAsync(logger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService, configFile, correlationId: correlationId); - }, configOption, verboseOption); + + Agent365Config? bootstrapConfig = null; + if (!string.IsNullOrWhiteSpace(agentName)) + { + bootstrapConfig = await BuildBootstrapConfigForCleanupAsync( + agentName, tenantIdFlag, executor, graphApiService, logger); + if (bootstrapConfig is null) + { + context.ExitCode = 1; + return; + } + } + + IConfirmationProvider effectiveConfirmationProvider = yes + ? new NonInteractiveConfirmationProvider() + : confirmationProvider; + + await ExecuteAllCleanupAsync(logger, configService, botConfigurator, executor, agentBlueprintService, effectiveConfirmationProvider, federatedCredentialService, configFile, graphApiService, correlationId: correlationId, configOverride: bootstrapConfig, ct: context.GetCancellationToken()); + }); // Add subcommands for granular control - cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService)); + cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService, graphApiService: graphApiService)); cleanupCommand.AddCommand(CreateAzureCleanupCommand(logger, configService, executor, authValidator)); cleanupCommand.AddCommand(CreateInstanceCleanupCommand(logger, configService, executor)); @@ -80,7 +119,8 @@ private static Command CreateBlueprintCleanupCommand( AgentBlueprintService agentBlueprintService, IConfirmationProvider confirmationProvider, FederatedCredentialService federatedCredentialService, - string? correlationId = null) + string? correlationId = null, + GraphApiService? graphApiService = null) { var command = new Command("blueprint", "Remove Entra ID blueprint application and service principal"); @@ -158,6 +198,16 @@ private static Command CreateBlueprintCleanupCommand( logger.LogInformation("Will delete Entra ID application: {BlueprintId}", config.AgentBlueprintId); logger.LogInformation(" Name: {DisplayName}", config.AgentBlueprintDisplayName); + if (!string.IsNullOrWhiteSpace(config.AgenticAppId)) + { + logger.LogInformation(""); + logger.LogInformation("Will also delete Agent Identity Service Principal: {SpId}", config.AgenticAppId); + } + if (!string.IsNullOrWhiteSpace(config.AgentInstanceId)) + { + logger.LogInformation(""); + logger.LogInformation("Will also deregister Agent Instance: {InstanceId}", config.AgentInstanceId); + } if (instances.Count > 0) { logger.LogInformation(""); @@ -178,6 +228,79 @@ private static Command CreateBlueprintCleanupCommand( return; } + if (!string.IsNullOrWhiteSpace(config.AgenticAppId)) + { + logger.LogInformation("Deleting agent identity service principal {SpId}...", config.AgenticAppId); + var identityDeleted = await agentBlueprintService.DeleteAgentIdentityAsync( + config.TenantId, + config.AgenticAppId); + + if (identityDeleted) + { + logger.LogInformation("Agent identity service principal deleted"); + config.AgenticAppId = string.Empty; + await configService.SaveStateAsync(config); + } + else + { + logger.LogWarning("Failed to delete agent identity service principal {SpId} -- will continue with cleanup", config.AgenticAppId); + } + } + + if (!string.IsNullOrWhiteSpace(config.AgentRegistrationId)) + { + if (graphApiService is null) + { + logger.LogWarning("Agent registration deletion skipped (GraphApiService not available). Delete registration {RegistrationId} manually.", config.AgentRegistrationId); + } + else + { + logger.LogInformation("Deleting agent registration {RegistrationId} via Graph API...", config.AgentRegistrationId); + var registrationDeleted = await graphApiService.DeleteAgentRegistrationAsync( + config.TenantId, + config.AgentRegistrationId, + CancellationToken.None); + + if (registrationDeleted) + { + logger.LogInformation("Agent registration deleted"); + config.AgentRegistrationId = string.Empty; + await configService.SaveStateAsync(config); + } + else + { + logger.LogWarning("Failed to delete agent registration {RegistrationId} -- will continue with cleanup", config.AgentRegistrationId); + } + } + } + + if (!string.IsNullOrWhiteSpace(config.AgentInstanceId)) + { + if (graphApiService is null) + { + logger.LogWarning("Agent instance deletion skipped (GraphApiService not available). Delete instance {InstanceId} manually via the M365 Admin Center.", config.AgentInstanceId); + } + else + { + logger.LogInformation("Deleting agent instance {InstanceId} from Agent Registry...", config.AgentInstanceId); + var instanceDeleted = await graphApiService.DeleteAgentInstanceAsync( + config.TenantId, + config.AgentInstanceId, + CancellationToken.None); + + if (instanceDeleted) + { + logger.LogInformation("Agent instance deleted from registry"); + config.AgentInstanceId = string.Empty; + await configService.SaveStateAsync(config); + } + else + { + logger.LogWarning("Failed to delete agent instance {InstanceId} -- will continue with blueprint deletion", config.AgentInstanceId); + } + } + } + // Delete instances first (warn and continue on failure) var failedResources = new Dictionary> { @@ -270,21 +393,7 @@ private static Command CreateBlueprintCleanupCommand( logger.LogInformation("Agent blueprint application deleted successfully"); - bool endpointDeleted = false; - try - { - endpointDeleted = await DeleteMessagingEndpointAsync(logger, config, botConfigurator, correlationId: correlationId); - } - finally - { - // Always emit orphan summary before returning, regardless of endpoint deletion outcome - PrintOrphanSummary(logger, failedResources); - } - - if (!endpointDeleted) - { - return; - } + PrintOrphanSummary(logger, failedResources); // Clear configuration after successful blueprint deletion logger.LogInformation(""); @@ -292,6 +401,8 @@ private static Command CreateBlueprintCleanupCommand( config.AgentBlueprintId = string.Empty; config.AgentBlueprintClientSecret = string.Empty; + config.AgenticAppId = string.Empty; + config.AgentInstanceId = string.Empty; config.ResourceConsents.Clear(); await configService.SaveStateAsync(config); @@ -472,7 +583,7 @@ private static Command CreateInstanceCleanupCommand( logger.LogInformation("Will delete the following resources:"); if (!string.IsNullOrWhiteSpace(config.AgenticAppId)) - logger.LogInformation(" Agent Identity Application: {IdentityId}", config.AgenticAppId); + logger.LogInformation(" Agent Identity Service Principal: {SpId}", config.AgenticAppId); if (!string.IsNullOrWhiteSpace(config.AgenticUserId)) logger.LogInformation(" Agent User: {UserId}", config.AgenticUserId); logger.LogInformation(" Generated configuration file"); @@ -486,12 +597,12 @@ private static Command CreateInstanceCleanupCommand( return; } - // Delete agent identity application + // Delete agent identity service principal if (!string.IsNullOrWhiteSpace(config.AgenticAppId)) { - logger.LogInformation("Deleting agent identity application..."); + logger.LogInformation("Deleting agent identity service principal..."); await executor.ExecuteAsync("az", $"ad app delete --id {config.AgenticAppId}", null, true, false, CancellationToken.None); - logger.LogInformation("Agent identity application deleted"); + logger.LogInformation("Agent identity service principal deleted"); } // Delete agent user @@ -564,15 +675,18 @@ private static async Task ExecuteAllCleanupAsync( IConfirmationProvider confirmationProvider, FederatedCredentialService federatedCredentialService, FileInfo? configFile, - string? correlationId = null) + GraphApiService? graphApiService = null, + string? correlationId = null, + Agent365Config? configOverride = null, + CancellationToken ct = default) { var cleanupSucceeded = false; var hasFailures = false; try { logger.LogInformation("Starting complete cleanup..."); - - var config = await LoadConfigAsync(configFile, logger, configService); + + var config = configOverride ?? await LoadConfigAsync(configFile, logger, configService); if (config == null) return; // Configure AgentBlueprintService with custom client app ID if available @@ -587,8 +701,14 @@ private static async Task ExecuteAllCleanupAsync( logger.LogInformation("WARNING: ALL RESOURCES WILL BE DELETED:"); if (!string.IsNullOrWhiteSpace(config.AgentBlueprintId)) logger.LogInformation(" Blueprint Application: {BlueprintId}", config.AgentBlueprintId); + if (!string.IsNullOrWhiteSpace(config.AgentBlueprintServicePrincipalObjectId)) + logger.LogInformation(" Blueprint Service Principal: {SpId}", config.AgentBlueprintServicePrincipalObjectId); if (!string.IsNullOrWhiteSpace(config.AgenticAppId)) - logger.LogInformation(" Agent Identity Application: {IdentityId}", config.AgenticAppId); + logger.LogInformation(" Agent Identity Service Principal: {SpId}", config.AgenticAppId); + if (!string.IsNullOrWhiteSpace(config.AgentRegistrationId)) + logger.LogInformation(" Agent Registration (AgentX): {RegistrationId}", config.AgentRegistrationId); + if (!string.IsNullOrWhiteSpace(config.AgentInstanceId)) + logger.LogInformation(" Agent Registry Instance: {InstanceId}", config.AgentInstanceId); if (!string.IsNullOrWhiteSpace(config.AgenticUserId)) logger.LogInformation(" Agent User: {UserId}", config.AgenticUserId); if (!string.IsNullOrWhiteSpace(config.WebAppName)) @@ -599,7 +719,10 @@ private static async Task ExecuteAllCleanupAsync( logger.LogInformation(" Azure Messaging Endpoint: {BotName}", config.BotName); if (!string.IsNullOrWhiteSpace(config.Location)) logger.LogInformation(" Location: {Location}", config.Location); - logger.LogInformation(" Generated configuration file"); + var previewLocalGen = Path.Combine(Environment.CurrentDirectory, "a365.generated.config.json"); + var previewGlobalGen = Path.Combine(ConfigService.GetGlobalConfigDirectory(), "a365.generated.config.json"); + if (File.Exists(previewLocalGen) || File.Exists(previewGlobalGen)) + logger.LogInformation(" Generated configuration file"); logger.LogInformation(""); if (!await confirmationProvider.ConfirmAsync("Are you sure you want to DELETE ALL resources? (y/N): ")) @@ -616,6 +739,64 @@ private static async Task ExecuteAllCleanupAsync( logger.LogInformation("Starting complete cleanup..."); + // 1a. For non-DW blueprint flow: delete AgentX agent registration before blueprint + if (!string.IsNullOrWhiteSpace(config.AgentRegistrationId)) + { + if (graphApiService is null) + { + logger.LogWarning("Agent registration deletion skipped (GraphApiService not available). Delete registration {RegistrationId} manually.", config.AgentRegistrationId); + hasFailures = true; + } + else + { + logger.LogInformation("Deleting agent registration {RegistrationId} via Graph API...", config.AgentRegistrationId); + var registrationDeleted = await graphApiService.DeleteAgentRegistrationAsync( + config.TenantId, + config.AgentRegistrationId, + ct); + + if (registrationDeleted) + { + logger.LogInformation("Agent registration deleted"); + config.AgentRegistrationId = string.Empty; + } + else + { + logger.LogWarning("Failed to delete agent registration {RegistrationId} -- will continue with blueprint deletion", config.AgentRegistrationId); + hasFailures = true; + } + } + } + + // 1b. For non-DW blueprint flow: delete Agent Registry instance before blueprint + if (!string.IsNullOrWhiteSpace(config.AgentInstanceId)) + { + if (graphApiService is null) + { + logger.LogWarning("Agent instance deletion skipped (GraphApiService not available). Delete instance {InstanceId} manually via the M365 Admin Center.", config.AgentInstanceId); + hasFailures = true; + } + else + { + logger.LogInformation("Deleting agent instance {InstanceId} from Agent Registry...", config.AgentInstanceId); + var instanceDeleted = await graphApiService.DeleteAgentInstanceAsync( + config.TenantId, + config.AgentInstanceId, + ct); + + if (instanceDeleted) + { + logger.LogInformation("Agent instance deleted from registry"); + config.AgentInstanceId = string.Empty; + } + else + { + logger.LogWarning("Failed to delete agent instance {InstanceId} -- will continue with blueprint deletion", config.AgentInstanceId); + hasFailures = true; + } + } + } + // 1. Delete federated credentials from agent blueprint (if exists) if (!string.IsNullOrWhiteSpace(config.AgentBlueprintId)) { @@ -663,27 +844,72 @@ private static async Task ExecuteAllCleanupAsync( } } - // 3. Delete agent identity application + // 3. Delete agent identity service principal(s). + // First delete the one recorded in config (fast path, no extra Graph query). + // Then query Entra for any additional identities linked to the blueprint that + // may not be in config — mirrors what 'cleanup blueprint' does, and handles the + // case where AgenticAppId is missing (e.g. bootstrap cleanup without --agent-name). + var deletedIdentityIds = new HashSet(StringComparer.OrdinalIgnoreCase); if (!string.IsNullOrWhiteSpace(config.AgenticAppId)) { - logger.LogInformation("Deleting agent identity application..."); + logger.LogInformation("Deleting agent identity service principal..."); var deleted = await agentBlueprintService.DeleteAgentIdentityAsync( config.TenantId, - config.AgenticAppId); + config.AgenticAppId, + ct); if (deleted) { - logger.LogInformation("Agent identity application deleted successfully"); + deletedIdentityIds.Add(config.AgenticAppId); + logger.LogInformation("Agent identity service principal deleted successfully"); } else { - logger.LogWarning("Failed to delete agent identity application (will continue with other resources)"); + logger.LogWarning("Failed to delete agent identity service principal (will continue with other resources)"); logger.LogWarning("Local configuration will still be cleared at the end"); hasFailures = true; } } + // Discover any remaining linked identities via Entra (handles IDs missing from config). + if (!string.IsNullOrWhiteSpace(config.AgentBlueprintId) && graphApiService != null) + { + try + { + var linkedInstances = await agentBlueprintService.GetAgentInstancesForBlueprintAsync( + config.TenantId, config.AgentBlueprintId, ct); + + foreach (var instance in linkedInstances) + { + if (string.IsNullOrWhiteSpace(instance.IdentitySpId) || + deletedIdentityIds.Contains(instance.IdentitySpId)) + continue; + + logger.LogInformation("Deleting linked agent identity SP {SpId} ({DisplayName})...", + instance.IdentitySpId, instance.DisplayName ?? "(unnamed)"); + + var deleted = await agentBlueprintService.DeleteAgentIdentityAsync( + config.TenantId, instance.IdentitySpId, ct); + + if (deleted) + { + deletedIdentityIds.Add(instance.IdentitySpId); + logger.LogInformation("Linked agent identity SP deleted"); + } + else + { + logger.LogWarning("Failed to delete linked agent identity SP {SpId}", instance.IdentitySpId); + hasFailures = true; + } + } + } + catch (Exception ex) + { + logger.LogWarning("Could not query linked agent identities from Entra (non-fatal): {Message}", ex.Message); + } + } + // 4. Delete agent user if (!string.IsNullOrWhiteSpace(config.AgenticUserId)) { @@ -976,6 +1202,137 @@ private static void PrintOrphanSummary( logger.LogWarning("Delete them manually via the Entra portal or Graph API."); } + /// + /// Builds a cleanup config from the global generated config without requiring a static config file. + /// Used when cleanup is invoked with --agent-name after a bootstrap setup. + /// Loads resource IDs (blueprint, agent identity, registration) from the generated config saved + /// to the global config directory by setup all --agent-name. + /// + private static async Task BuildBootstrapConfigForCleanupAsync( + string agentName, + string? tenantIdFlag, + CommandExecutor executor, + GraphApiService? graphApiService, + ILogger logger) + { + // Step 1: Resolve tenant ID + var tenantId = await SetupHelpers.ResolveBootstrapTenantIdAsync(tenantIdFlag, executor, logger); + if (string.IsNullOrWhiteSpace(tenantId)) + { + logger.LogError("Could not detect tenant ID. Sign in with 'az login' or pass --tenant-id."); + return null; + } + + // Step 2: Resolve client app ID. + // Prefer a365.config.json when it exists locally and its tenant matches the current tenant. + // Fall back to Entra lookup by well-known display name if the static config is absent or stale. + var clientAppId = await SetupHelpers.ResolveBootstrapClientAppIdAsync( + tenantId, + graphApiService, + logger, + CancellationToken.None, + preferLocalConfig: true); + if (!string.IsNullOrWhiteSpace(clientAppId) && graphApiService != null) + graphApiService.CustomClientAppId = clientAppId; + + // Step 3: Resolve blueprint ID from Entra by display name (authoritative source). + var blueprintDisplayName = $"{agentName} Blueprint"; + string? resolvedBlueprintId = null; + if (graphApiService != null) + { + resolvedBlueprintId = await graphApiService.FindApplicationByDisplayNameAsync( + tenantId, blueprintDisplayName); + if (string.IsNullOrWhiteSpace(resolvedBlueprintId)) + logger.LogWarning("Blueprint '{Name}' not found in Entra — resource IDs may be incomplete.", blueprintDisplayName); + } + + // Step 4: Load generated config. + // Only take agentRegistrationId from the file when the blueprint IDs match, + // confirming the file belongs to this agent. + var localGeneratedPath = Path.Combine(Environment.CurrentDirectory, "a365.generated.config.json"); + var globalGeneratedPath = Path.Combine(ConfigService.GetGlobalConfigDirectory(), "a365.generated.config.json"); + var generatedConfigPath = File.Exists(localGeneratedPath) ? localGeneratedPath : globalGeneratedPath; + + string? agentRegistrationId = null; + string? agenticAppId = null; + string? agentBlueprintSpObjectId = null; + string? configBlueprintId = null; + + if (File.Exists(generatedConfigPath)) + { + try + { + var json = await File.ReadAllTextAsync(generatedConfigPath); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + configBlueprintId = SetupHelpers.GetJsonString(root, "agentBlueprintId"); + + if (!string.IsNullOrWhiteSpace(resolvedBlueprintId) && + string.Equals(resolvedBlueprintId, configBlueprintId, StringComparison.OrdinalIgnoreCase)) + { + agentRegistrationId = SetupHelpers.GetJsonString(root, "agentRegistrationId"); + agenticAppId = SetupHelpers.GetJsonString(root, "AgenticAppId"); + agentBlueprintSpObjectId = SetupHelpers.GetJsonString(root, "agentBlueprintServicePrincipalObjectId"); + logger.LogInformation("Loaded resource IDs from {Path}", generatedConfigPath); + } + else if (!string.IsNullOrWhiteSpace(configBlueprintId) && !string.IsNullOrWhiteSpace(resolvedBlueprintId)) + { + logger.LogWarning( + "Generated config blueprint ID ({ConfigId}) does not match Entra-resolved ID ({ResolvedId}). Skipping resource IDs from file.", + configBlueprintId, resolvedBlueprintId); + } + else if (string.IsNullOrWhiteSpace(resolvedBlueprintId)) + { + // Entra lookup failed — fall back to file values for all IDs + agentRegistrationId = SetupHelpers.GetJsonString(root, "agentRegistrationId"); + agenticAppId = SetupHelpers.GetJsonString(root, "AgenticAppId"); + agentBlueprintSpObjectId = SetupHelpers.GetJsonString(root, "agentBlueprintServicePrincipalObjectId"); + logger.LogInformation("Loaded resource IDs from {Path} (Entra lookup unavailable)", generatedConfigPath); + } + } + catch (Exception ex) + { + logger.LogWarning("Could not read generated config at {Path}: {Message}", generatedConfigPath, ex.Message); + } + } + else + { + logger.LogWarning("No generated config found at {Path}. Resource IDs may be missing — resources must be deleted manually.", generatedConfigPath); + } + + var blueprintId = resolvedBlueprintId ?? configBlueprintId; + + var config = new Agent365Config + { + TenantId = tenantId, + ClientAppId = clientAppId ?? string.Empty, + AgentIdentityDisplayName = $"{agentName} Identity", + AgentBlueprintDisplayName = blueprintDisplayName, + AgentDescription = agentName, + NeedDeployment = false, + AiTeammate = false, + UseBlueprint = true, + }; + + config.AgentBlueprintId = blueprintId; + config.AgentBlueprintServicePrincipalObjectId = agentBlueprintSpObjectId; + config.AgentRegistrationId = agentRegistrationId; + config.AgenticAppId = agenticAppId; + + logger.LogInformation("Bootstrap cleanup config:"); + using (logger.Indent()) + { + logger.LogInformation("TenantId: {TenantId}", tenantId); + logger.LogInformation("ClientAppId: {ClientAppId}", clientAppId ?? "(not found)"); + logger.LogInformation("BlueprintId: {BlueprintId}", blueprintId ?? "(not found)"); + logger.LogInformation("BlueprintSP: {SpId}", agentBlueprintSpObjectId ?? "(not found)"); + logger.LogInformation("AgentIdentitySP: {SpId}", agenticAppId ?? "(not found)"); + logger.LogInformation("RegistrationId: {RegId}", agentRegistrationId ?? "(not found)"); + } + + return config; + } + private static async Task LoadConfigAsync( FileInfo? configFile, ILogger logger, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigSubcommands/PermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigSubcommands/PermissionsSubcommand.cs index 4e04e7ab..53bc29b6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigSubcommands/PermissionsSubcommand.cs @@ -31,14 +31,13 @@ public static Command CreateCommand(ILogger logger, string configDir) bool reset = context.ParseResult.GetValueForOption(resetOption); bool force = context.ParseResult.GetValueForOption(forceOption); - // Resolve config path: current directory first, then global fallback - var localConfigPath = Path.Combine(Environment.CurrentDirectory, "a365.config.json"); - var globalConfigPath = Path.Combine(configDir, "a365.config.json"); - var configPath = File.Exists(localConfigPath) ? localConfigPath : globalConfigPath; + // Only read from the current directory — never fall back to the global config directory. + // A stale global config from a different project would silently corrupt permissions. + var configPath = Path.Combine(Environment.CurrentDirectory, "a365.config.json"); if (!File.Exists(configPath)) { - logger.LogError("Configuration file not found. Run 'a365 config init' first to create a base configuration."); + logger.LogError("Configuration file not found in the current directory. Run 'a365 config init' first to create a base configuration."); context.ExitCode = 1; return; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs index 4eeb99da..8ed46d82 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs @@ -107,9 +107,75 @@ public static Command CreateCommand(ILogger logger, IConf logger.LogInformation(" Agent User ID: {AgenticUserId}", instanceConfig.AgenticUserId ?? "(not set)"); logger.LogInformation(" Agent User Principal Name: {AgentUserPrincipalName}", instanceConfig.AgentUserPrincipalName ?? "(not set)"); - // The runner grants required permissions (from constants) merged with - // resourceConsents from the generated config via consentType=AllPrincipals. - logger.LogInformation("Permission grants configured for agent identity"); + // Admin consent for MCP scopes (oauth2PermissionGrants) + logger.LogInformation("Granting MCP scopes to Agent Identity via oauth2PermissionGrants"); + + var manifestPath = Path.Combine(instanceConfig.DeploymentProjectPath ?? string.Empty, McpConstants.ToolingManifestFileName); + var scopesForAgent = await ManifestHelper.GetRequiredScopesAsync(manifestPath); + + // clientId must be the *service principal objectId* of the agentic app + var agenticAppSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync( + instanceConfig.TenantId, + instanceConfig.AgenticAppId ?? string.Empty + ) ?? throw new InvalidOperationException($"Service Principal not found for agentic app Id {instanceConfig.AgenticAppId}"); + + var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(instanceConfig.Environment); + var agent365ToolsResourceSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync(instanceConfig.TenantId, resourceAppId) + ?? throw new InvalidOperationException("Agent 365 Tools Service Principal not found for appId " + resourceAppId); + + var mcpGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( + instanceConfig.TenantId, + agenticAppSpObjectId, + agent365ToolsResourceSpObjectId, + scopesForAgent + ); + + if (!mcpGrantOk) + logger.LogWarning("Failed to create/update oauth2PermissionGrant for agent identity (MCP scopes)."); + + logger.LogInformation(""); + logger.LogInformation("Granting Bot Framework API scopes to Agent Identity"); + + var botApiResourceSpObjectId = await graphApiService.EnsureServicePrincipalForAppIdAsync( + instanceConfig.TenantId, + ConfigConstants.MessagingBotApiAppId) + ?? throw new InvalidOperationException( + $"Failed to resolve service principal for Messaging Bot API (appId {ConfigConstants.MessagingBotApiAppId})."); + + // Grant oauth2PermissionGrants: *agent identity SP* -> Messaging Bot API SP + var botApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( + instanceConfig.TenantId, + agenticAppSpObjectId, + botApiResourceSpObjectId, + new[] { "Authorization.ReadWrite", "user_impersonation" }); + + if (!botApiGrantOk) + logger.LogWarning("Failed to create/update oauth2PermissionGrant for agent identity to Messaging Bot API."); + + var observabilityApiResourceSpObjectId = await graphApiService.EnsureServicePrincipalForAppIdAsync( + instanceConfig.TenantId, + ConfigConstants.ObservabilityApiAppId) + ?? throw new InvalidOperationException( + $"Failed to resolve service principal for Observability API (appId {ConfigConstants.ObservabilityApiAppId})."); + + // Grant oauth2PermissionGrants: *agent identity SP* -> Observability API SP + var observabilityApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( + instanceConfig.TenantId, + agenticAppSpObjectId, + observabilityApiResourceSpObjectId, + new[] { "user_impersonation", ConfigConstants.ObservabilityApiOtelWriteScope }); + + if (!observabilityApiGrantOk) + logger.LogWarning("Failed to create/update oauth2PermissionGrant for agent identity to Observability API."); + + var adminConsentGrantOk = mcpGrantOk && botApiGrantOk && observabilityApiGrantOk; + if (!adminConsentGrantOk) + { + logger.LogError("Admin consent for Agent Identity completed with errors. One or more required API grants failed and follow-up action is required."); + throw new InvalidOperationException("Admin consent for Agent Identity did not complete successfully for all required API grants."); + } + + logger.LogInformation("Admin consent granted for Agent Identity completed successfully"); // Register agent with Microsoft Graph API logger.LogInformation(" Registering agent with Microsoft Graph API"); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs index f814d775..6f5bb9a1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs @@ -467,7 +467,7 @@ private static async Task EnsureMcpInheritablePermissionsAsync( if (!ok && !alreadyExists) { throw new InvalidOperationException("Failed to set inheritable permissions: " + err + - ". Ensure you have AgentIdentityBlueprint.UpdateAuthProperties.All and Application.ReadWrite.All permissions in your custom client app."); + ". Ensure you have AgentIdentityBlueprint.UpdateAuthProperties.All permission in your custom client app."); } logger.LogInformation(" - Inheritable permissions completed: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]", diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs index 0ce9e9cd..ea2d0b4c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs @@ -194,16 +194,16 @@ public static Command CreateCommand( if (success) { - logger.LogInformation(" [SUCCESS] Successfully added permissions for {ResourceAppId}", resourceAppId); + logger.LogInformation(" Added permissions for {ResourceAppId}", resourceAppId); } else { - logger.LogError(" [FAILED] Failed to add permissions for {ResourceAppId}", resourceAppId); + logger.LogError(" Failed to add permissions for {ResourceAppId}", resourceAppId); } } catch (Exception ex) { - logger.LogError(" [ERROR] Exception adding permissions for {ResourceAppId}: {Message}", resourceAppId, ex.Message); + logger.LogError(" Exception adding permissions for {ResourceAppId}: {Message}", resourceAppId, ex.Message); logger.LogDebug(" {StackTrace}", ex.StackTrace); success = false; } @@ -215,7 +215,7 @@ public static Command CreateCommand( if (success) { - logger.LogInformation("[SUCCESS] All permissions added successfully!"); + logger.LogInformation("All permissions added successfully"); logger.LogInformation(""); logger.LogInformation(" Review permissions in Azure Portal: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/CallAnAPI/appId/{AppId}", targetAppId); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs index 97489321..048a1a35 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs @@ -54,23 +54,73 @@ private static string GetProjectDirectory(Agent365Config config, ILogger logger) public static Command CreateCommand( ILogger logger, IConfigService configService, - ManifestTemplateService manifestTemplateService) + ManifestTemplateService manifestTemplateService, + GraphApiService? graphApiService = null) { var command = new Command("publish", "Update manifest IDs and create a package for upload to Microsoft 365 Admin Center"); var dryRunOption = new Option("--dry-run", "Show changes without writing files or creating the zip"); + var aiTeammateOption = new Option( + "--aiteammate", + description: "true = AI Teammate / Digital Worker (default), false = non-AI Teammate agent\n" + + "Overrides the aiTeammate field in a365.config.json"); + + var useBlueprintOption = new Option( + "--use-blueprint", + description: "Use the blueprint-based non-DW flow (calls Agent Instance Graph API, no manifest).\n" + + "Only meaningful with --aiteammate false"); + command.AddOption(dryRunOption); + command.AddOption(aiTeammateOption); + command.AddOption(useBlueprintOption); command.SetHandler(async (System.CommandLine.Invocation.InvocationContext context) => { var dryRun = context.ParseResult.GetValueForOption(dryRunOption); + var aiTeammateFlag = context.ParseResult.GetValueForOption(aiTeammateOption); + var useBlueprintFlag = context.ParseResult.GetValueForOption(useBlueprintOption); var isNormalExit = false; try { var config = await configService.LoadAsync(); + + // Effective agent type: CLI flag > config value > default (digital-worker) + var isNonAiTeammate = + aiTeammateFlag == false || + (!aiTeammateFlag.HasValue && config.IsNonAiTeammate); + + if (isNonAiTeammate) + { + var isBlueprint = useBlueprintFlag || (isNonAiTeammate && config.UseBlueprint == true); + + if (dryRun) + { + if (isBlueprint) + PrintNonDwBlueprintDryRunPlan(config, logger); + else + PrintNonDwDryRunPlan(config, logger); + isNormalExit = true; + return; + } + + if (isBlueprint) + { + isNormalExit = await PublishBlueprintNonDwAsync(config, graphApiService, configService, logger, context, ct: context.GetCancellationToken()); + return; + } + + // App-based non-DW Phase B not yet implemented — team feedback on dry-run output first. + logger.LogError( + "App-based non-DW publish (Phase B) is not yet implemented. " + + "Run with --dry-run to preview the manifest substitution plan."); + context.ExitCode = 1; + return; + } + + // --- Digital Worker (default) path --- var blueprintId = config.AgentBlueprintId; var displayName = config.AgentBlueprintDisplayName; @@ -157,7 +207,8 @@ public static Command CreateCommand( Console.Write("Press Enter when you have finished editing the manifest to continue: "); Console.Out.Flush(); - Console.ReadLine(); + if (Console.ReadLine() is null) + throw new OperationCanceledException(); Console.WriteLine(); } @@ -192,6 +243,115 @@ 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) + ? config.ClientAppId + : ""; + + var webAppDomain = !string.IsNullOrWhiteSpace(config.WebAppName) + ? $"{config.WebAppName}.azurewebsites.net" + : ".azurewebsites.net"; + + logger.LogInformation("Non-DW Publish Plan (dry run — no files will be written)"); + logger.LogInformation(""); + logger.LogInformation(" Source of truth : ClientAppId = {ClientAppId}", clientAppId); + logger.LogInformation(""); + logger.LogInformation(" Fields to substitute:"); + logger.LogInformation(" id -> {ClientAppId}", clientAppId); + logger.LogInformation(" bots[0].botId -> {ClientAppId}", clientAppId); + logger.LogInformation(" copilotAgents.customEngineAgents[0].id -> {ClientAppId}", clientAppId); + logger.LogInformation(" validDomains[1] -> {Domain}", webAppDomain); + logger.LogInformation(" webApplicationInfo.id -> {ClientAppId}", clientAppId); + logger.LogInformation(" webApplicationInfo.resource -> api://botid-{ClientAppId}", clientAppId); + logger.LogInformation(""); + logger.LogInformation(" Zip contents:"); + logger.LogInformation(" manifest.json"); + logger.LogInformation(" color.png"); + logger.LogInformation(" outline.png"); + logger.LogInformation(""); + logger.LogInformation("Run without --dry-run to write the manifest files and create the zip."); + } + private static async Task UpdateManifestFileAsync(string? displayName, string blueprintId, string manifestPath) { var manifestText = await File.ReadAllTextAsync(manifestPath); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 729fe00a..75841f10 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -41,7 +41,8 @@ public static Command CreateCommand( FederatedCredentialService federatedCredentialService, IClientAppValidator clientAppValidator, IConfirmationProvider confirmationProvider, - ArmApiService? armApiService = null) + ArmApiService? armApiService = null, + IEnumerable? requirementChecksOverride = null) { var command = new Command("setup", "Set up your Agent 365 environment with granular control over each step\n\n" + @@ -59,7 +60,7 @@ public static Command CreateCommand( // Add subcommands command.AddCommand(RequirementsSubcommand.CreateCommand( - logger, configService, authValidator, clientAppValidator)); + logger, configService, authValidator, clientAppValidator, requirementChecksOverride)); command.AddCommand(InfrastructureSubcommand.CreateCommand( logger, configService, authValidator, platformDetector, executor)); @@ -71,7 +72,7 @@ public static Command CreateCommand( logger, authValidator, configService, executor, graphApiService, blueprintService, confirmationProvider)); command.AddCommand(AllSubcommand.CreateCommand( - logger, configService, executor, botConfigurator, authValidator, platformDetector, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService, armApiService)); + logger, configService, executor, botConfigurator, authValidator, platformDetector, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService, armApiService, confirmationProvider)); command.AddCommand(AdminSubcommand.CreateCommand( logger, configService, authValidator, graphApiService, confirmationProvider, blueprintService)); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AdminSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AdminSubcommand.cs index fcee4b93..9855f5c0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AdminSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AdminSubcommand.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.Agents.A365.DevTools.Cli.Commands; @@ -45,15 +45,24 @@ public static Command CreateCommand( var command = new Command( "admin", "Complete OAuth2 permission grants that require Global Administrator.\n\n" + - "Run this after 'a365 setup all' has been executed by an Agent ID Admin or Developer.\n" + - "Point --config-dir at the folder containing the agent's a365.config.json and\n" + - "a365.generated.config.json files.\n\n" + + "Run this after 'a365 setup all' has been executed by an Agent ID Admin or Developer.\n\n" + + "Two modes:\n" + + " --blueprint-id Config-free. Pass the blueprint ID shown in 'a365 setup all' output.\n" + + " Creates Observability API and Power Platform API grants only.\n" + + " Tenant is auto-detected from 'az account show'.\n" + + " --config-dir Full mode. Loads config files and creates grants for all APIs\n" + + " configured in a365.config.json.\n\n" + "Required permissions:\n" + - " - Global Administrator\n\n" + + " - Global Administrator (for OAuth2 grants)\n" + + " - Agent Registry Administrator (for non-DW agent instance registration — optional)\n\n" + "Typical handoff workflow:\n" + - " 1. Agent ID Admin runs: a365 setup all\n" + - " 2. Agent ID Admin shares the config folder with a Global Administrator\n" + - " 3. Global Admin runs: a365 setup admin --config-dir \"\""); + " 1. Agent ID Developer runs: a365 setup all --agent-name \n" + + " 2. Global Admin runs: a365 setup admin --blueprint-id "); + + var blueprintIdOption = new Option( + ["--blueprint-id", "-id"], + description: "Blueprint app ID (client ID). Config-free mode: skips loading config files.\n" + + "Use the ID shown in the 'a365 setup all' output. Tenant is auto-detected from 'az account show'."); var configDirOption = new Option( ["--config-dir", "-d"], @@ -77,6 +86,7 @@ public static Command CreateCommand( ["--yes", "-y"], description: "Skip confirmation prompt and proceed automatically"); + command.AddOption(blueprintIdOption); command.AddOption(configDirOption); command.AddOption(verboseOption); command.AddOption(dryRunOption); @@ -85,6 +95,7 @@ public static Command CreateCommand( command.SetHandler(async (System.CommandLine.Invocation.InvocationContext ctx) => { + var blueprintId = ctx.ParseResult.GetValueForOption(blueprintIdOption); var configDir = ctx.ParseResult.GetValueForOption(configDirOption)!; var dryRun = ctx.ParseResult.GetValueForOption(dryRunOption); var skipRequirements = ctx.ParseResult.GetValueForOption(skipRequirementsOption); @@ -96,17 +107,24 @@ public static Command CreateCommand( if (dryRun) { - logger.LogInformation("DRY RUN: Admin Permission Grants"); - logger.LogInformation("This would execute the following operations:"); + logger.LogInformation("Dry run: a365 setup admin --dry-run"); + logger.LogInformation(""); + logger.LogInformation("The following steps would be performed."); logger.LogInformation(""); - if (!skipRequirements) - logger.LogInformation(" 0. Validate prerequisites"); + if (!string.IsNullOrWhiteSpace(blueprintId)) + { + logger.LogInformation(SetupHelpers.DryRunRow(1, "Prerequisites") + "validate (az account show — tenant detection)"); + logger.LogInformation(SetupHelpers.DryRunRow(2, "Blueprint") + "resolve (service principal lookup for {BlueprintId})", blueprintId); + logger.LogInformation(SetupHelpers.DryRunRow(3, "Permission Grants") + "grant tenant-wide for Observability API, Power Platform API"); + } else - logger.LogInformation(" 0. [SKIPPED] Requirements validation (--skip-requirements flag used)"); - logger.LogInformation(" 1. Load configuration from: {ConfigDir}", configDir.FullName); - logger.LogInformation(" 2. Resolve blueprint and resource service principals"); - logger.LogInformation(" 3. Create AllPrincipals OAuth2 grants for all configured resources"); - logger.LogInformation("No actual changes will be made."); + { + logger.LogInformation(SetupHelpers.DryRunRow(1, "Prerequisites") + (skipRequirements ? "skip (--skip-requirements)" : "validate")); + logger.LogInformation(SetupHelpers.DryRunRow(2, "Blueprint") + "resolve from config: {ConfigDir}", configDir.FullName); + logger.LogInformation(SetupHelpers.DryRunRow(3, "Permission Grants") + "grant tenant-wide (resource set from configuration)"); + } + logger.LogInformation(""); + logger.LogInformation("No changes will be made. Run without --dry-run to apply."); return; } @@ -114,80 +132,81 @@ public static Command CreateCommand( try { - var configPath = Path.Combine(configDir.FullName, "a365.config.json"); - if (!File.Exists(configPath)) - { - logger.LogError( - "Configuration file not found: {ConfigPath}", - configPath); - logger.LogError( - "Ensure the Agent ID Admin has run 'a365 setup all' and shared the config folder."); - ExceptionHandler.ExitWithCleanup(1); - return; - } - - var setupConfig = await configService.LoadAsync(configPath); + Agent365Config setupConfig; + List specs; + bool isBlueprintIdMode = !string.IsNullOrWhiteSpace(blueprintId); - if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - graphApiService.CustomClientAppId = setupConfig.ClientAppId; - - if (!skipRequirements) + if (isBlueprintIdMode) { - var checks = GetChecks(authValidator); - try + // Config-free path: admin received only the blueprint ID from the developer. + // Detect tenant from az account; grant Observability + Power Platform only. + var tenantId = await TenantDetectionHelper.DetectTenantIdAsync(null, logger); + if (string.IsNullOrWhiteSpace(tenantId)) { - await RequirementsSubcommand.RunChecksOrExitAsync( - checks, setupConfig, logger, ct); + logger.LogError("Could not detect tenant ID. Run 'az login' and ensure an account is selected."); + ExceptionHandler.ExitWithCleanup(1); + return; } - catch (Exception reqEx) when (reqEx is not OperationCanceledException && reqEx is not CleanExitException) + + // Resolve the well-known CLI client app so Graph auth uses delegated permissions. + // FindApplicationByDisplayNameAsync uses the default (az CLI) token path and + // does not require CustomClientAppId to be set beforehand. + var clientAppId = await graphApiService.FindApplicationByDisplayNameAsync( + tenantId, AuthenticationConstants.WellKnownClientAppDisplayName, ct); + if (!string.IsNullOrWhiteSpace(clientAppId)) + graphApiService.CustomClientAppId = clientAppId; + + setupConfig = new Agent365Config { TenantId = tenantId, AgentBlueprintId = blueprintId }; + specs = SetupHelpers.GetNonDwFixedApiPermissionSpecs(setInheritable: false).ToList(); + } + else + { + // Config-dir path: load full config from disk. + var configPath = Path.Combine(configDir.FullName, "a365.config.json"); + if (!File.Exists(configPath)) { - logger.LogError(reqEx, "Requirements check failed: {Message}", reqEx.Message); - logger.LogError("Rerun with --skip-requirements to bypass."); + logger.LogError( + "Configuration file not found: {ConfigPath}", + configPath); + logger.LogError( + "Ensure the Agent ID Admin has run 'a365 setup all' and shared the config folder, " + + "or pass --blueprint-id to skip config file loading."); ExceptionHandler.ExitWithCleanup(1); + return; } - } - if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) - { - logger.LogError( - "AgentBlueprintId is missing from the generated config. " + - "Ensure 'a365 setup all' completed blueprint creation before running this command."); - ExceptionHandler.ExitWithCleanup(1); - return; - } + setupConfig = await configService.LoadAsync(configPath); - // Build the same spec list as 'setup all' so all resources get grants. - var mcpManifestPath = Path.Combine( - setupConfig.DeploymentProjectPath ?? string.Empty, - McpConstants.ToolingManifestFileName); - var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(mcpManifestPath, excludeLegacyAtg: false); + if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) + graphApiService.CustomClientAppId = setupConfig.ClientAppId; - var specs = new List - { - new ResourcePermissionSpec( - AuthenticationConstants.MicrosoftGraphResourceAppId, - "Microsoft Graph", - setupConfig.AgentApplicationScopes.ToArray(), - SetInheritable: false), - }; - specs.AddRange(scopesByAudience.Select(kvp => - new ResourcePermissionSpec(kvp.Key, "Agent 365 Tools", kvp.Value, SetInheritable: false))); - specs.AddRange(SetupHelpers.GetFixedApiPermissionSpecs(setInheritable: false)); - - foreach (var customPerm in setupConfig.CustomBlueprintPermissions ?? new List()) - { - var (isValid, _) = customPerm.Validate(); - if (isValid && !string.IsNullOrWhiteSpace(customPerm.ResourceAppId)) + if (!skipRequirements) + { + var checks = GetChecks(authValidator); + try + { + await RequirementsSubcommand.RunChecksOrExitAsync( + checks, setupConfig, logger, ct); + } + catch (Exception reqEx) when (reqEx is not OperationCanceledException && reqEx is not CleanExitException) + { + logger.LogError("Requirements check failed: {Message}", reqEx.Message); + logger.LogDebug(reqEx, "Requirements check exception details"); + logger.LogInformation("To bypass requirement validation, rerun with --skip-requirements."); + ExceptionHandler.ExitWithCleanup(1); + } + } + + if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) { - var resourceName = string.IsNullOrWhiteSpace(customPerm.ResourceName) - ? customPerm.ResourceAppId - : customPerm.ResourceName; - specs.Add(new ResourcePermissionSpec( - customPerm.ResourceAppId, - resourceName, - customPerm.Scopes.ToArray(), - SetInheritable: false)); + logger.LogError( + "AgentBlueprintId is missing from the generated config. " + + "Ensure 'a365 setup all' completed blueprint creation before running this command."); + ExceptionHandler.ExitWithCleanup(1); + return; } + + specs = await SetupHelpers.BuildConfiguredPermissionSpecsAsync(setupConfig, setInheritable: false); } // Display what will be done and ask for confirmation (unless --yes is set). @@ -205,8 +224,6 @@ await RequirementsSubcommand.RunChecksOrExitAsync( logger.LogInformation(""); logger.LogInformation("Running admin permission grants... (TraceId: {TraceId})", correlationId); - if (skipRequirements) - logger.LogInformation("NOTE: Requirements validation skipped (--skip-requirements flag used)"); (bool grantsConfigured, string? blueprintSpObjectId) = await BatchPermissionsOrchestrator.GrantAdminPermissionsAsync( @@ -218,7 +235,62 @@ await BatchPermissionsOrchestrator.GrantAdminPermissionsAsync( setupResults.AdminConsentGranted = grantsConfigured; + // Agent instance registration: config-dir path only — display name not available in blueprint-id mode. + // This requires 'Agent Registry Administrator' role -- separate from Global Administrator. + // The admin running this command may or may not hold that role. We attempt it and report. + if (!isBlueprintIdMode && setupConfig.IsNonDwBlueprint) + { + if (!string.IsNullOrWhiteSpace(setupConfig.AgentInstanceId)) + { + logger.LogInformation("Agent instance already registered (ID: {InstanceId}). Skipping.", setupConfig.AgentInstanceId); + setupResults.AgentInstanceRegistered = true; + setupResults.AgentInstanceId = setupConfig.AgentInstanceId; + } + else + { + logger.LogInformation(""); + logger.LogInformation("Non-DW blueprint flow: attempting agent instance registration..."); + logger.LogInformation("NOTE: This step requires 'Agent Registry Administrator' role - separate from Global Administrator."); + + var agentDisplayName = setupConfig.AgentIdentityDisplayName + ?? setupConfig.WebAppName + ?? "Agent"; + + var instanceId = await graphApiService.RegisterAgentInstanceAsync( + setupConfig.TenantId!, + agentDisplayName, + setupConfig.AgentBlueprintId, + ct); + + if (instanceId is not null) + { + setupConfig.AgentInstanceId = instanceId; + await configService.SaveStateAsync(setupConfig); + setupResults.AgentInstanceRegistered = true; + setupResults.AgentInstanceId = instanceId; + logger.LogInformation("Agent instance registered (ID: {InstanceId})", instanceId); + } + else + { + logger.LogWarning( + "Agent instance registration failed - 'Agent Registry Administrator' role is not assigned " + + "for this account. The developer must get that role assigned by a tenant admin and run: " + + "a365 setup all --aiteammate false --agent-instance-only"); + } + } + } + SetupHelpers.DisplayAdminSetupSummary(setupResults, blueprintSpObjectId, logger); + + // For autonomous/S2S agents, application-type permissions are needed once resource + // APIs publish app roles. Until then, the delegated grants above serve as a bridge. + if (isBlueprintIdMode) + { + logger.LogInformation(""); + logger.LogInformation("Note: For autonomous/S2S agents, application-type permissions will be required once available."); + logger.LogInformation(" Grant them in the Entra portal: App registrations > > API permissions > Grant admin consent"); + logger.LogInformation(" Microsoft Admin Center: https://admin.microsoft.com"); + } } catch (Agent365Exception ex) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs index 2917e6cf..27eacc83 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -7,11 +7,14 @@ using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Extensions.Logging; using System.CommandLine; +using System.Linq; +using System.Text.Json; namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; @@ -48,6 +51,34 @@ internal static class AllSubcommand return checks; } + /// + /// Returns the requirement checks for setup all --aiteammate false (non-DW blueprint). + /// Mirrors DW checks: includes Location and optionally Infrastructure when the agent needs deployment. + /// + public static List GetNonDwChecks( + AzureAuthValidator auth, + IClientAppValidator clientAppValidator, + bool includeInfrastructure, + bool isBootstrap = false) + { + var checks = new List(SetupCommand.GetBaseChecks(auth)); + + // Location and client app checks require a static config file — not applicable in bootstrap + // mode where the client app is resolved dynamically via --agent-name. + if (!isBootstrap) + { + checks.Add(new LocationRequirementCheck()); + checks.Add(new ClientAppRequirementCheck(clientAppValidator)); + } + + if (includeInfrastructure) + { + checks.Add(new InfrastructureRequirementCheck()); + } + + return checks; + } + public static Command CreateCommand( ILogger logger, IConfigService configService, @@ -60,9 +91,10 @@ public static Command CreateCommand( IClientAppValidator clientAppValidator, BlueprintLookupService blueprintLookupService, FederatedCredentialService federatedCredentialService, - ArmApiService? armApiService = null) + ArmApiService? armApiService = null, + IConfirmationProvider? confirmationProvider = null) { - var command = new Command("all", + var command = new Command("all", "Run complete Agent 365 setup (all steps in sequence)\n" + "Includes: Infrastructure + Blueprint + Permissions + Endpoint\n\n" + "Minimum required permissions (Global Administrator has all of these):\n" + @@ -93,11 +125,35 @@ public static Command CreateCommand( description: "Skip requirements validation check\n" + "Use with caution: setup may fail if prerequisites are not met"); + var aiTeammateOption = new Option( + "--aiteammate", + description: "true = AI Teammate / Digital Worker, false = non-AI Teammate agent (blueprint, default)\n" + + "Overrides the aiTeammate field in a365.config.json"); + + var agentInstanceOnlyOption = new Option( + "--agent-instance-only", + description: "Skip all setup steps and only run agent instance registration (--aiteammate false only)"); + + var agentNameOption = new Option( + ["--agent-name", "-n"], + description: "Agent base name (e.g. \"MyAgent\"). When provided, no config file is required.\n" + + "Derives AgentIdentityDisplayName=\" Identity\" and AgentBlueprintDisplayName=\" Blueprint\".\n" + + "TenantId is auto-detected from 'az account show' (override with --tenant-id).\n" + + $"ClientAppId is resolved by looking up \"{Constants.AuthenticationConstants.WellKnownClientAppDisplayName}\" in your tenant."); + + var tenantIdOption = new Option( + "--tenant-id", + description: "Azure AD tenant ID. Overrides auto-detection from 'az account show'."); + command.AddOption(configOption); command.AddOption(verboseOption); command.AddOption(dryRunOption); command.AddOption(skipInfrastructureOption); command.AddOption(skipRequirementsOption); + command.AddOption(aiTeammateOption); + command.AddOption(agentInstanceOnlyOption); + command.AddOption(agentNameOption); + command.AddOption(tenantIdOption); command.SetHandler(async (System.CommandLine.Invocation.InvocationContext context) => { @@ -105,45 +161,158 @@ public static Command CreateCommand( var dryRun = context.ParseResult.GetValueForOption(dryRunOption); var skipInfrastructure = context.ParseResult.GetValueForOption(skipInfrastructureOption); var skipRequirements = context.ParseResult.GetValueForOption(skipRequirementsOption); + var aiTeammateFlag = context.ParseResult.GetValueForOption(aiTeammateOption); + var agentInstanceOnly = context.ParseResult.GetValueForOption(agentInstanceOnlyOption); + var agentName = context.ParseResult.GetValueForOption(agentNameOption); + var tenantIdFlag = context.ParseResult.GetValueForOption(tenantIdOption); var ct = context.GetCancellationToken(); // Generate correlation ID at workflow entry point var correlationId = HttpClientFactory.GenerateCorrelationId(); logger.LogDebug("Starting setup all (CorrelationId: {CorrelationId})", correlationId); - if (dryRun) - { - logger.LogInformation("DRY RUN: Complete Agent 365 Setup"); - logger.LogInformation("This would execute the following operations:"); - logger.LogInformation(""); + // --- Agent type resolution --- + // Non-DW (blueprint) is the default. DW requires --aiteammate true explicitly. + Agent365Config? nonDwConfig = null; + bool isBootstrap = !string.IsNullOrWhiteSpace(agentName); - if (!skipRequirements) + if (aiTeammateFlag != true) + { + if (isBootstrap) { - logger.LogInformation(" 0. Validate prerequisites (PowerShell modules, etc.)"); + if (dryRun) + { + // Dry-run: detect tenant only (no client app lookup needed for display) + var dryRunTenantId = tenantIdFlag; + if (string.IsNullOrWhiteSpace(dryRunTenantId)) + dryRunTenantId = await SetupHelpers.ResolveBootstrapTenantIdAsync(null, executor, logger); + nonDwConfig = new Agent365Config + { + TenantId = dryRunTenantId ?? "(unknown — run 'az login' or pass --tenant-id)", + ClientAppId = string.Empty, + AgentIdentityDisplayName = $"{agentName} Identity", + AgentBlueprintDisplayName = $"{agentName} Blueprint", + AgentDescription = agentName, + NeedDeployment = false, + AiTeammate = false, + UseBlueprint = true, + }; + } + else + { + // Print banner first so it appears before any auth output + var bootstrapRawArgs = context.ParseResult.Tokens.Select(t => t.Value).ToArray(); + logger.LogInformation("Running \"a365 {Args}\"...", string.Join(" ", bootstrapRawArgs)); + logger.LogInformation(""); + + // Real run: resolve client app ID from Entra + nonDwConfig = await BuildBootstrapConfigAsync( + agentName!, tenantIdFlag, executor, graphApiService, logger, ct); + if (nonDwConfig is null) + { + context.ExitCode = 1; + return; + } + + // Log resolved config so the user can verify the inferred values + logger.LogInformation("Bootstrap config resolved:"); + using (logger.Indent()) + { + logger.LogInformation("TenantId: {TenantId}", nonDwConfig.TenantId); + logger.LogInformation("ClientAppId: {ClientAppId}", nonDwConfig.ClientAppId); + logger.LogInformation("BlueprintDisplayName: {Name}", nonDwConfig.AgentBlueprintDisplayName); + logger.LogInformation("IdentityDisplayName: {Name}", nonDwConfig.AgentIdentityDisplayName); + logger.LogInformation("NeedDeployment: {NeedDeployment}", nonDwConfig.NeedDeployment); + } + logger.LogInformation(""); + + // If existing config files belong to a different tenant (e.g. the user ran + // 'az login' with a different account), back them up and remove them so this + // run starts with a clean state and does not inherit stale resource IDs. + await BackupAndClearStaleConfigAsync(config.FullName, nonDwConfig.TenantId!, logger); + + // Write a365.config.json so the resolved bootstrap settings are persisted in the + // current working directory and reused consistently by later setup and cleanup steps. + if (!File.Exists(config.FullName)) + await WriteBootstrapConfigFileAsync(nonDwConfig, config.FullName, logger); + } } else { - logger.LogInformation(" 0. [SKIPPED] Requirements validation (--skip-requirements flag used)"); + // Config file path: load from a365.config.json, merged with generated config when present. + var nonDwGenPath = Path.Combine(config.DirectoryName ?? Environment.CurrentDirectory, "a365.generated.config.json"); + nonDwConfig = File.Exists(nonDwGenPath) + ? await configService.LoadAsync(config.FullName, nonDwGenPath) + : await configService.LoadAsync(config.FullName); + // If aiTeammate was not explicitly set, respect what the config says + // (allows existing DW configs to keep working without --aiteammate true) + if (!aiTeammateFlag.HasValue && !nonDwConfig.IsNonAiTeammate && !dryRun) + nonDwConfig = null; // fall through to DW path } - - if (!skipInfrastructure) + } + + if (nonDwConfig is not null) + { + if (dryRun) { - logger.LogInformation(" 1. Create Azure infrastructure"); + var rawArgs = context.ParseResult.Tokens.Select(t => t.Value).ToArray(); + NonDwBlueprintSetupOrchestrator.PrintDryRunPlan(nonDwConfig, logger, isBootstrap, rawArgs, skipRequirements); + return; } - else + + // Build SetupContext for non-DW blueprint and delegate to orchestrator. + if (!string.IsNullOrWhiteSpace(nonDwConfig.ClientAppId)) + graphApiService.CustomClientAppId = nonDwConfig.ClientAppId; + + var nonDwGeneratedConfigPath = Path.Combine( + config.DirectoryName ?? Environment.CurrentDirectory, + "a365.generated.config.json"); + + var nonDwCtx = new SetupContext( + config: nonDwConfig, + results: new SetupResults(), + logger: logger, + configFile: config, + generatedConfigPath: nonDwGeneratedConfigPath, + correlationId: correlationId, + skipInfrastructure: skipInfrastructure || isBootstrap, + skipRequirements: skipRequirements, + cancellationToken: ct, + configService: configService, + executor: executor, + botConfigurator: botConfigurator, + authValidator: authValidator, + platformDetector: platformDetector, + graphApiService: graphApiService, + blueprintService: blueprintService, + blueprintLookupService: blueprintLookupService, + federatedCredentialService: federatedCredentialService, + clientAppValidator: clientAppValidator, + agentInstanceOnly: agentInstanceOnly, + isBootstrap: isBootstrap, + confirmationProvider: confirmationProvider); + + context.ExitCode = await NonDwBlueprintSetupOrchestrator.ExecuteAsync(nonDwCtx); + return; + } + + // --- Digital Worker (default) path --- + if (dryRun) + { + var rawArgs = context.ParseResult.Tokens.Select(t => t.Value).ToArray(); + Agent365Config? dwDryRunConfig = null; + try { - logger.LogInformation(" 1. [SKIPPED] Azure infrastructure (--skip-infrastructure flag used)"); + var dwGenPath = Path.Combine(config.DirectoryName ?? Environment.CurrentDirectory, "a365.generated.config.json"); + dwDryRunConfig = File.Exists(dwGenPath) + ? await configService.LoadAsync(config.FullName, dwGenPath) + : await configService.LoadAsync(config.FullName); } - - logger.LogInformation(" 2. Create agent blueprint (Entra ID application)"); - logger.LogInformation(" 3. Configure MCP server permissions"); - logger.LogInformation(" 4. Configure Bot API permissions"); - logger.LogInformation(" 5. Register blueprint messaging endpoint and sync project settings"); - logger.LogInformation("No actual changes will be made."); + catch { /* config is optional for dry-run display */ } + SetupHelpers.PrintDwSetupAllDryRunPlan(logger, skipInfrastructure, skipRequirements, rawArgs, dwDryRunConfig); return; } - var setupResults = new SetupResults(); try @@ -158,6 +327,9 @@ public static Command CreateCommand( graphApiService.CustomClientAppId = setupConfig.ClientAppId; } + setupResults.PrerequisitesSkipped = skipRequirements; + setupResults.InfrastructureSkipped = skipInfrastructure || !setupConfig.NeedDeployment; + // Validate all prerequisites in one pass if (!skipRequirements) { @@ -171,8 +343,9 @@ await RequirementsSubcommand.RunChecksOrExitAsync( } catch (Exception reqEx) when (reqEx is not OperationCanceledException && reqEx is not CleanExitException) { - logger.LogError(reqEx, "Requirements check failed with an unexpected error: {Message}", reqEx.Message); - logger.LogError("If you want to bypass requirement validation, rerun this command with the --skip-requirements flag."); + logger.LogError("Requirements check failed: {Message}", reqEx.Message); + logger.LogDebug(reqEx, "Requirements check exception details"); + logger.LogInformation("To bypass requirement validation, rerun with --skip-requirements."); ExceptionHandler.ExitWithCleanup(1); } } @@ -190,236 +363,50 @@ await RequirementsSubcommand.RunChecksOrExitAsync( config.DirectoryName ?? Environment.CurrentDirectory, "a365.generated.config.json"); - // Step 1: Infrastructure (optional) - try - { - - var (setupInfra, infraAlreadyExisted) = await InfrastructureSubcommand.CreateInfrastructureImplementationAsync( - logger, - config.FullName, - generatedConfigPath, - executor, - platformDetector, - setupConfig.NeedDeployment, - skipInfrastructure, - ct, - armApiService, - graphApiService); - - setupResults.InfrastructureCreated = skipInfrastructure ? false : setupInfra; - setupResults.InfrastructureAlreadyExisted = infraAlreadyExisted; - } - catch (Agent365Exception infraEx) - { - setupResults.InfrastructureCreated = false; - setupResults.Errors.Add($"Infrastructure: {infraEx.Message}"); - throw; - } - catch (Exception infraEx) - { - setupResults.InfrastructureCreated = false; - setupResults.Errors.Add($"Infrastructure: {infraEx.Message}"); - logger.LogError("Failed to create infrastructure: {Message}", infraEx.Message); - throw; - } + // Build the shared step context for the DW flow. + var ctx = new SetupContext( + config: setupConfig, + results: setupResults, + logger: logger, + configFile: config, + generatedConfigPath: generatedConfigPath, + correlationId: correlationId, + skipInfrastructure: skipInfrastructure, + skipRequirements: skipRequirements, + cancellationToken: ct, + configService: configService, + executor: executor, + botConfigurator: botConfigurator, + authValidator: authValidator, + platformDetector: platformDetector, + graphApiService: graphApiService, + blueprintService: blueprintService, + blueprintLookupService: blueprintLookupService, + federatedCredentialService: federatedCredentialService, + clientAppValidator: clientAppValidator); + + // Step 1: Infrastructure (optional, DW only) + await ExecuteInfrastructureStepAsync(ctx); // Step 2: Blueprint - try - { - var result = await BlueprintSubcommand.CreateBlueprintImplementationAsync( - setupConfig, - config, - executor, - authValidator, - logger, - skipInfrastructure, - true, - configService, - botConfigurator, - platformDetector, - graphApiService, - blueprintService, - blueprintLookupService, - federatedCredentialService, - skipEndpointRegistration: true, - correlationId: correlationId, - options: new BlueprintCreationOptions(DeferConsent: true)); - - setupResults.BlueprintCreated = result.BlueprintCreated; - setupResults.BlueprintAlreadyExisted = result.BlueprintAlreadyExisted; - setupResults.ClientSecretManualActionRequired = result.ClientSecretManualActionRequired; - - // Graph permissions and admin consent are deferred to the batch orchestrator - // (DeferConsent: true above). Flags are updated in Step 4 after the orchestrator runs. - if (result.GraphInheritablePermissionsFailed) - { - setupResults.GraphInheritablePermissionsError = result.GraphInheritablePermissionsError - ?? "Microsoft Graph inheritable permissions failed to configure"; - setupResults.Warnings.Add($"Microsoft Graph inheritable permissions: {setupResults.GraphInheritablePermissionsError}"); - } - else - { - setupResults.GraphInheritablePermissionsConfigured = true; - } + await ExecuteBlueprintStepAsync(ctx); - // Track Federated Identity Credential status - setupResults.FederatedCredentialConfigured = result.FederatedCredentialConfigured; - if (!result.FederatedCredentialConfigured && !string.IsNullOrWhiteSpace(result.FederatedCredentialError)) - { - setupResults.FederatedCredentialError = result.FederatedCredentialError; - setupResults.Warnings.Add($"Federated Identity Credential: {result.FederatedCredentialError}"); - } + // Step 3: Configure all permissions in a batch. + var (specs, mcpResourceAppId, mcpScopes) = await BuildPermissionSpecsAsync(ctx); - if (!result.BlueprintCreated) - { - throw new GraphApiException( - operation: "Create Agent Blueprint", - reason: "Blueprint creation failed. This typically indicates missing permissions or insufficient privileges.", - isPermissionIssue: true); - } + await ExecuteBatchPermissionsStepAsync( + ctx, specs, + knownBlueprintSpObjectId: ctx.Config.AgentBlueprintServicePrincipalObjectId); - // CRITICAL: Wait for file system to ensure config file is fully written - // Blueprint creation writes directly to disk and may not be immediately readable - logger.LogDebug("Waiting for config file write to complete..."); - await Task.Delay(2000, ct); + SetupHelpers.ApplyConsentUrlsIfNeeded(ctx, mcpResourceAppId, ctx.Config.AgentApplicationScopes, mcpScopes); - // Reload config to get blueprint ID - // Use full path to ensure we're reading from the correct location - var fullConfigPath = Path.GetFullPath(config.FullName); - setupConfig = await configService.LoadAsync(fullConfigPath); - setupResults.BlueprintId = setupConfig.AgentBlueprintId; + await ctx.ConfigService.SaveStateAsync(ctx.Config); - // Validate blueprint ID was properly saved - if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) - { - throw new SetupValidationException( - "Blueprint creation completed but AgentBlueprintId was not saved to configuration. " + - "This is required for the next steps (MCP permissions and Bot permissions)."); - } - - // Warn when service principal creation failed (SP object ID missing after blueprint creation). - // Setup continues because inheritable permissions use the blueprint objectId, not the SP. - // However, agent token exchange will not work until the SP exists. - if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintServicePrincipalObjectId)) - { - var spWarning = "Agent blueprint service principal was not created. " + - "Inheritable permissions and FIC may not function correctly. " + - "Run 'a365 setup blueprint' to retry SP creation."; - setupResults.Warnings.Add(spWarning); - logger.LogWarning(spWarning); - } - } - catch (Agent365Exception blueprintEx) - { - setupResults.BlueprintCreated = false; - setupResults.MessagingEndpointRegistered = false; - setupResults.Errors.Add($"Blueprint: {blueprintEx.Message}"); - throw; - } - catch (Exception blueprintEx) - { - setupResults.BlueprintCreated = false; - setupResults.MessagingEndpointRegistered = false; - setupResults.Errors.Add($"Blueprint: {blueprintEx.Message}"); - logger.LogError("Failed to create blueprint: {Message}", blueprintEx.Message); - throw; - } - - // Step 3: Configure all permissions (Graph + MCP + Bot x3 + Custom) in a single batch. - // Phase 1 — update blueprint requiredResourceAccess + resolve SPs once (non-admin). - // Phase 2 — create OAuth2 grants and inheritable permissions (non-admin). - // Phase 3 — single admin consent browser or one consolidated URL for non-admins. - try - { - // Pre-step: remove stale custom permissions before building the spec list. - var desiredCustomIds = new HashSet( - (setupConfig.CustomBlueprintPermissions ?? new List()) - .Select(p => p.ResourceAppId), - StringComparer.OrdinalIgnoreCase); - await PermissionsSubcommand.RemoveStaleCustomPermissionsAsync( - logger, graphApiService, blueprintService, setupConfig, desiredCustomIds, ct); - - // Build combined spec list. - var mcpManifestPath = Path.Combine( - setupConfig.DeploymentProjectPath ?? string.Empty, - McpConstants.ToolingManifestFileName); - var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(mcpManifestPath, excludeLegacyAtg: false); - - // Derive ATG-AppId entry for consent URL helpers (V1 backward compat). - // V2-only manifests produce an empty array here, which is correct. - var mcpResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig.Environment); - var mcpScopes = scopesByAudience.TryGetValue(mcpResourceAppId, out var atgScopes) - ? atgScopes - : Array.Empty(); - - var specs = new List - { - new ResourcePermissionSpec( - AuthenticationConstants.MicrosoftGraphResourceAppId, - "Microsoft Graph", - setupConfig.AgentApplicationScopes.ToArray(), - SetInheritable: true), - }; - specs.AddRange(scopesByAudience.Select(kvp => - new ResourcePermissionSpec(kvp.Key, "Agent 365 Tools", kvp.Value, SetInheritable: true))); - specs.AddRange(SetupHelpers.GetFixedApiPermissionSpecs(setInheritable: true)); - - foreach (var customPerm in setupConfig.CustomBlueprintPermissions ?? new List()) - { - var (isValid, _) = customPerm.Validate(); - if (isValid && !string.IsNullOrWhiteSpace(customPerm.ResourceAppId)) - { - var resourceName = string.IsNullOrWhiteSpace(customPerm.ResourceName) - ? customPerm.ResourceAppId - : customPerm.ResourceName; - specs.Add(new ResourcePermissionSpec( - customPerm.ResourceAppId, - resourceName, - customPerm.Scopes.ToArray(), - SetInheritable: true)); - } - } - - var (blueprintPermissionsUpdated, inheritedPermissionsConfigured, consentGranted, adminConsentUrl) = - await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( - graphApiService, blueprintService, setupConfig, - setupConfig.AgentBlueprintId!, setupConfig.TenantId, - specs, logger, setupResults, ct, - knownBlueprintSpObjectId: setupConfig.AgentBlueprintServicePrincipalObjectId); - - setupResults.BatchPermissionsPhase1Completed = blueprintPermissionsUpdated; - setupResults.BatchPermissionsPhase2Completed = inheritedPermissionsConfigured; - setupResults.AdminConsentGranted = consentGranted; - setupResults.AdminConsentUrl = adminConsentUrl; - - List? consentResourceNames = null; - if (!consentGranted && !string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) - { - consentResourceNames = SetupHelpers.PopulateAdminConsentUrls(setupConfig, mcpResourceAppId, mcpScopes); - } - - await configService.SaveStateAsync(setupConfig); - - // Only advertise the path after the save has succeeded — the file must exist - // before we tell the caller where to find the consent URLs. - if (consentResourceNames is not null) - { - setupResults.ConsentUrlsSavedToPath = generatedConfigPath; - setupResults.ConsentResourceNames.AddRange(consentResourceNames); - setupResults.CombinedConsentUrl = SetupHelpers.BuildCombinedConsentUrl( - setupConfig.TenantId!, setupConfig.AgentBlueprintId!, - setupConfig.AgentApplicationScopes, mcpScopes); - } - } - catch (Exception permEx) - { - setupResults.BatchPermissionsPhase2Completed = false; - setupResults.AdminConsentGranted = false; - setupResults.Errors.Add($"Permissions: {permEx.Message}"); - logger.LogWarning("Permissions configuration failed: {Message}. Setup will continue, but permissions must be configured manually.", permEx.Message); - } - - // Step 4: Messaging endpoint registration is temporarily disabled. + // Sync all settings (ServiceConnection, TokenValidation, Agent365Observability) to the app config file. + await ProjectSettingsSyncHelper.ExecuteAsync( + ctx.ConfigFile.FullName, ctx.GeneratedConfigPath, + ctx.ConfigService, ctx.PlatformDetector, ctx.Logger); + setupResults.ProjectSettingsWritten = true; // Display verification URLs and setup summary await SetupHelpers.DisplayVerificationInfoAsync(config, logger); @@ -430,24 +417,445 @@ await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( { var logFilePath = ConfigService.GetCommandLogPath(CommandNames.Setup); ExceptionHandler.HandleAgent365Exception(ex, logFilePath: logFilePath); + setupResults.Errors.Add(ex.Message); + logger.LogInformation(""); + SetupHelpers.DisplaySetupSummary(setupResults, logger); ExceptionHandler.ExitWithCleanup(1); } catch (FileNotFoundException fnfEx) { logger.LogError("Setup failed: {Message}", fnfEx.Message); + setupResults.Errors.Add(fnfEx.Message); + logger.LogInformation(""); + SetupHelpers.DisplaySetupSummary(setupResults, logger); ExceptionHandler.ExitWithCleanup(1); } - catch (OperationCanceledException) - { - throw; - } catch (Exception ex) { logger.LogError(ex, "Setup failed: {Message}", ex.Message); + setupResults.Errors.Add(ex.Message); + logger.LogInformation(""); + SetupHelpers.DisplaySetupSummary(setupResults, logger); throw; } }); return command; } + + // ------------------------------------------------------------------------- + // Shared step methods — called by both DW (AllSubcommand) and non-DW + // (NonDwBlueprintSetupOrchestrator). Steps are intentionally non-fatal + // when appropriate (Permissions) so partial progress is preserved and + // the caller can report what succeeded. + // ------------------------------------------------------------------------- + + /// + /// Step 2 — Creates or reuses the Agent Identity Blueprint in Entra. + /// Reloads from disk after blueprint writes + /// AgentBlueprintId to the generated config file. + /// Throws on fatal failure so the caller's outer try/catch can handle it. + /// + internal static async Task ExecuteBlueprintStepAsync(SetupContext ctx) + { + try + { + var result = await BlueprintSubcommand.CreateBlueprintImplementationAsync( + ctx.Config, + ctx.ConfigFile, + ctx.Executor, + ctx.AuthValidator, + ctx.Logger, + ctx.SkipInfrastructure, + isSetupAll: true, + ctx.ConfigService, + ctx.BotConfigurator, + ctx.PlatformDetector, + ctx.GraphApiService, + ctx.BlueprintService, + ctx.BlueprintLookupService, + ctx.FederatedCredentialService, + skipEndpointRegistration: true, + correlationId: ctx.CorrelationId, + cancellationToken: ctx.CancellationToken, + options: new BlueprintCreationOptions(DeferConsent: true), + loginHintResolver: ctx.LoginHintResolver); + + ctx.Results.BlueprintCreated = result.BlueprintCreated; + ctx.Results.BlueprintAlreadyExisted = result.BlueprintAlreadyExisted; + + // Graph permissions and admin consent are deferred to the batch orchestrator + // (DeferConsent: true above). Flags are updated in the batch permissions step. + if (result.GraphInheritablePermissionsFailed) + { + ctx.Results.GraphInheritablePermissionsError = result.GraphInheritablePermissionsError + ?? "Microsoft Graph inheritable permissions failed to configure"; + ctx.Results.Warnings.Add($"Microsoft Graph inheritable permissions: {ctx.Results.GraphInheritablePermissionsError}"); + } + else + { + ctx.Results.GraphInheritablePermissionsConfigured = true; + } + + ctx.Results.FederatedCredentialConfigured = result.FederatedCredentialConfigured; + if (!result.FederatedCredentialConfigured && !string.IsNullOrWhiteSpace(result.FederatedCredentialError)) + { + ctx.Results.FederatedCredentialError = result.FederatedCredentialError; + ctx.Results.Warnings.Add($"Federated Identity Credential: {result.FederatedCredentialError}"); + } + + if (result.ClientSecretManualActionRequired) + ctx.Results.ClientSecretManualActionRequired = true; + + if (!result.BlueprintCreated) + { + throw new GraphApiException( + operation: "Create Agent Blueprint", + reason: "Blueprint creation failed. This typically indicates missing permissions or insufficient privileges.", + isPermissionIssue: true); + } + + // In bootstrap mode, CreateBlueprintImplementationAsync already sets AgentBlueprintId + // (and related properties) directly on ctx.Config. The static a365.config.json does + // not exist on disk, so LoadAsync would throw ConfigFileNotFoundException. + if (!ctx.IsBootstrap) + { + // Reload config to get blueprint ID and any other dynamic properties written to disk. + // Retry up to 5 times with 500ms backoff to handle transient file-system flush delays. + var fullConfigPath = Path.GetFullPath(ctx.ConfigFile.FullName); + Agent365Config? reloaded = null; + for (var attempt = 0; attempt < 5; attempt++) + { + await Task.Delay(500, ctx.CancellationToken); + try + { + reloaded = await ctx.ConfigService.LoadAsync(fullConfigPath); + if (!string.IsNullOrWhiteSpace(reloaded.AgentBlueprintId)) + break; + } + catch (Exception ex) + { + ctx.Logger.LogDebug(ex, "Config reload attempt {Attempt} failed; retrying", attempt + 1); + } + } + if (reloaded is not null) + ctx.Config = reloaded; + } + ctx.Results.BlueprintId = ctx.Config.AgentBlueprintId; + ctx.Results.BlueprintDisplayName = ctx.Config.AgentBlueprintDisplayName; + + // Validate blueprint ID was properly saved + if (string.IsNullOrWhiteSpace(ctx.Config.AgentBlueprintId)) + { + throw new SetupValidationException( + "Blueprint creation completed but AgentBlueprintId was not saved to configuration. " + + "This is required for the next steps (MCP permissions and Bot permissions)."); + } + + // Track whether the service principal was created (SP object ID present after blueprint creation). + ctx.Results.BlueprintServicePrincipalCreated = !string.IsNullOrWhiteSpace(ctx.Config.AgentBlueprintServicePrincipalObjectId); + if (!ctx.Results.BlueprintServicePrincipalCreated) + { + var spWarning = "Agent blueprint service principal was not created. " + + "Inheritable permissions and FIC may not function correctly. " + + "Run 'a365 setup blueprint' to retry SP creation."; + ctx.Results.Warnings.Add(spWarning); + ctx.Logger.LogWarning(spWarning); + } + } + catch (Agent365Exception blueprintEx) + { + ctx.Results.BlueprintCreated = false; + ctx.Results.BlueprintFailed = true; + ctx.Results.MessagingEndpointRegistered = false; + ctx.Results.Errors.Add($"Blueprint: {blueprintEx.Message}"); + throw; + } + catch (Exception blueprintEx) + { + ctx.Results.BlueprintCreated = false; + ctx.Results.BlueprintFailed = true; + ctx.Results.MessagingEndpointRegistered = false; + ctx.Results.Errors.Add($"Blueprint: {blueprintEx.Message}"); + ctx.Logger.LogError("Failed to create blueprint: {Message}", blueprintEx.Message); + throw; + } + } + + /// + /// Step 3 (core) — Configures permissions for all supplied resource specs via the + /// three-phase . Updates + /// with phase outcomes. + /// + /// Non-fatal: a permissions failure logs a warning and continues so callers can + /// display a partial-success summary. State save is the caller's responsibility + /// (DW and non-DW have different post-processing before saving). + /// + internal static async Task ExecuteBatchPermissionsStepAsync( + SetupContext ctx, + List specs, + string? knownBlueprintSpObjectId = null) + { + try + { + var (blueprintPermissionsUpdated, inheritedPermissionsConfigured, consentGranted, adminConsentUrl) = + await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( + ctx.GraphApiService, ctx.BlueprintService, ctx.Config, + ctx.Config.AgentBlueprintId!, ctx.Config.TenantId!, + specs, ctx.Logger, ctx.Results, ctx.CancellationToken, + knownBlueprintSpObjectId: knownBlueprintSpObjectId); + + ctx.Results.BatchPermissionsPhase1Completed = blueprintPermissionsUpdated; + ctx.Results.BatchPermissionsPhase2Completed = inheritedPermissionsConfigured; + ctx.Results.AdminConsentGranted = consentGranted; + ctx.Results.AdminConsentUrl = adminConsentUrl; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception permEx) + { + ctx.Results.BatchPermissionsPhase2Completed = false; + ctx.Results.AdminConsentGranted = false; + ctx.Results.Errors.Add($"Permissions: {permEx.Message}"); + ctx.Logger.LogWarning("Permissions configuration failed: {Message}. Setup will continue, but permissions must be configured manually.", permEx.Message); + } + } + + /// + /// Step 3 (pre) — Removes stale custom permissions and builds the full resource permission + /// spec list from dynamic config values (AgentApplicationScopes, MCP manifest, CustomBlueprintPermissions). + /// Shared by both DW and non-DW flows so permissions are always consistent. + /// + internal static async Task<(List specs, string mcpResourceAppId, string[] mcpScopes)> BuildPermissionSpecsAsync(SetupContext ctx, bool isDw = true) + { + var desiredCustomIds = new HashSet( + (ctx.Config.CustomBlueprintPermissions ?? new List()) + .Select(p => p.ResourceAppId), + StringComparer.OrdinalIgnoreCase); + await PermissionsSubcommand.RemoveStaleCustomPermissionsAsync( + ctx.Logger, ctx.GraphApiService, ctx.BlueprintService, ctx.Config, desiredCustomIds, ctx.CancellationToken); + + var mcpManifestPath = Path.Combine( + ctx.Config.DeploymentProjectPath ?? string.Empty, + McpConstants.ToolingManifestFileName); + var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(mcpManifestPath, excludeLegacyAtg: false); + var mcpResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(ctx.Config.Environment); + // V1-compatible: extract ATG scopes for consent URL helpers (empty for V2-only manifests) + var mcpScopes = scopesByAudience.TryGetValue(mcpResourceAppId, out var atgScopes) ? atgScopes : Array.Empty(); + + List specs; + if (isDw) + { + // Pass the already-computed scopesByAudience to avoid reading the MCP manifest twice. + // BuildConfiguredPermissionSpecsAsync also handles custom permissions. + specs = await SetupHelpers.BuildConfiguredPermissionSpecsAsync(ctx.Config, setInheritable: true, scopesByAudience); + } + else + { + // Non-DW (blueprint) path: only Observability API and Power Platform API. + // Microsoft Graph, Agent 365 Tools (MCP), and Messaging Bot API are DW-only. + // To enable MCP or Messaging Bot API for non-DW, add them here and update + // the isDw guards in BuildAdminConsentUrls / BuildCombinedConsentUrl. + specs = [.. SetupHelpers.GetNonDwFixedApiPermissionSpecs(setInheritable: true)]; + + // Non-DW: custom permissions are not included by GetNonDwFixedApiPermissionSpecs. + // DW: custom permissions are already included by BuildConfiguredPermissionSpecsAsync above. + foreach (var customPerm in ctx.Config.CustomBlueprintPermissions ?? new List()) + { + var (isValid, _) = customPerm.Validate(); + if (isValid && !string.IsNullOrWhiteSpace(customPerm.ResourceAppId)) + { + var resourceName = string.IsNullOrWhiteSpace(customPerm.ResourceName) + ? customPerm.ResourceAppId + : customPerm.ResourceName; + specs.Add(new ResourcePermissionSpec( + customPerm.ResourceAppId, + resourceName, + customPerm.Scopes.ToArray(), + SetInheritable: true)); + } + } + } + + return (specs, mcpResourceAppId, mcpScopes); + } + + /// + /// Builds a minimal from without + /// requiring an a365.config.json file on disk. + /// + /// TenantId: from or auto-detected via az account show + /// ClientAppId: resolved by searching Entra for + /// NeedDeployment: false (external hosting, no Azure infra) + /// + /// Returns null and logs errors if validation fails. + /// + private static async Task BuildBootstrapConfigAsync( + string agentName, + string? tenantIdFlag, + CommandExecutor executor, + GraphApiService graphApiService, + ILogger logger, + CancellationToken ct) + { + // Resolve tenant ID + var tenantId = await SetupHelpers.ResolveBootstrapTenantIdAsync(tenantIdFlag, executor, logger); + if (tenantId is null) + return null; + + var clientAppId = await SetupHelpers.ResolveBootstrapClientAppIdAsync( + tenantId, + graphApiService, + logger, + ct); + if (!string.IsNullOrWhiteSpace(clientAppId)) + graphApiService.CustomClientAppId = clientAppId; + + // Build minimal config and validate + var config = new Agent365Config + { + TenantId = tenantId, + ClientAppId = clientAppId ?? string.Empty, + AgentIdentityDisplayName = $"{agentName} Identity", + AgentBlueprintDisplayName = $"{agentName} Blueprint", + AgentDescription = agentName, + NeedDeployment = false, + AiTeammate = false, + UseBlueprint = true, + }; + + var errors = config.ValidateNonDwMinimal(); + if (errors.Count > 0) + { + foreach (var err in errors) + logger.LogError("{Error}", err); + return null; + } + + return config; + } + + /// + /// Writes a minimal a365.config.json to from the bootstrap config so + /// that subsequent calls detect a local static config and + /// save the generated file to the local directory instead of the global %LocalAppData% directory. + /// Only the init-only (static) fields are persisted; dynamic/generated fields belong in + /// a365.generated.config.json and are written there by each setup step. + /// + private static async Task WriteBootstrapConfigFileAsync( + Agent365Config config, + string path, + ILogger logger) + { + var staticFields = new Dictionary + { + ["tenantId"] = config.TenantId, + ["clientAppId"] = config.ClientAppId, + ["agentIdentityDisplayName"] = config.AgentIdentityDisplayName, + ["agentBlueprintDisplayName"] = config.AgentBlueprintDisplayName, + ["agentDescription"] = config.AgentDescription, + ["needDeployment"] = config.NeedDeployment, + ["aiTeammate"] = config.AiTeammate, + ["useBlueprint"] = config.UseBlueprint, + }; + + var json = JsonSerializer.Serialize(staticFields, new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(path, json); + logger.LogDebug("Wrote bootstrap config to {Path}", path); + } + + /// + /// When running bootstrap setup (--agent-name), checks whether config files already in the + /// current directory belong to a different tenant than the one currently signed in. If so, + /// backs both files up with a timestamp suffix and removes the originals so setup starts clean + /// without inheriting stale resource IDs from a previous run. + /// + internal static async Task BackupAndClearStaleConfigAsync( + string configPath, + string resolvedTenantId, + ILogger logger) + { + if (!File.Exists(configPath)) + return; + + // Read tenantId from the existing static config without loading the full model. + // shouldBackup is true when: (a) the file is unreadable/malformed, or (b) the tenant + // is present and explicitly differs from the resolved tenant. + bool shouldBackup = false; + string? existingTenantId = null; + try + { + var json = await File.ReadAllTextAsync(configPath); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("tenantId", out var prop)) + { + existingTenantId = prop.GetString(); + shouldBackup = !string.IsNullOrWhiteSpace(existingTenantId) && + !string.Equals(existingTenantId, resolvedTenantId, StringComparison.OrdinalIgnoreCase); + } + } + catch + { + // Unreadable or malformed config — back it up so setup starts clean. + shouldBackup = true; + } + + if (!shouldBackup) + return; + + logger.LogWarning( + "Existing config files belong to tenant {OldTenant} but the current az login session " + + "is for tenant {NewTenant}. Backing up and removing stale config files to start clean.", + existingTenantId, resolvedTenantId); + + var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + var configDir = Path.GetDirectoryName(configPath) ?? Environment.CurrentDirectory; + + var configBackup = configPath + ".bak." + timestamp; + File.Move(configPath, configBackup); + logger.LogInformation(" Backed up: {File}", Path.GetFileName(configBackup)); + + var generatedPath = Path.Combine(configDir, "a365.generated.config.json"); + if (File.Exists(generatedPath)) + { + var generatedBackup = generatedPath + ".bak." + timestamp; + File.Move(generatedPath, generatedBackup); + logger.LogInformation(" Backed up: {File}", Path.GetFileName(generatedBackup)); + } + } + + /// Step 1 — Creates Azure infrastructure (optional, skippable via --skip-infrastructure). + internal static async Task ExecuteInfrastructureStepAsync(SetupContext ctx) + { + try + { + var (setupInfra, infraAlreadyExisted) = await InfrastructureSubcommand.CreateInfrastructureImplementationAsync( + ctx.Logger, + ctx.ConfigFile.FullName, + ctx.GeneratedConfigPath, + ctx.Executor, + ctx.PlatformDetector, + ctx.Config.NeedDeployment, + ctx.SkipInfrastructure, + ctx.CancellationToken); + + ctx.Results.InfrastructureCreated = (ctx.SkipInfrastructure || !ctx.Config.NeedDeployment) ? false : setupInfra; + ctx.Results.InfrastructureAlreadyExisted = infraAlreadyExisted; + } + catch (Agent365Exception infraEx) + { + ctx.Results.InfrastructureCreated = false; + ctx.Results.Errors.Add($"Infrastructure: {infraEx.Message}"); + throw; + } + catch (Exception infraEx) + { + ctx.Results.InfrastructureCreated = false; + ctx.Results.Errors.Add($"Infrastructure: {infraEx.Message}"); + ctx.Logger.LogError("Failed to create infrastructure: {Message}", infraEx.Message); + throw; + } + } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs index ed711d47..df79f246 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs @@ -99,10 +99,6 @@ internal static class BatchPermissionsOrchestrator var permScopes = AuthenticationConstants.RequiredPermissionGrantScopes; - // --- Resolve service principals --- - logger.LogInformation(""); - logger.LogInformation("Resolving service principals..."); - BlueprintPermissionsResult? phase1Result = null; var blueprintPermissionsUpdated = false; try @@ -129,8 +125,7 @@ internal static class BatchPermissionsOrchestrator // --- Phase 2a: Inheritable permissions (Agent ID Admin or GA) --- // --- Phase 2b: OAuth2 grants (Global Administrator only) --- - logger.LogInformation(""); - logger.LogInformation("Configuring inheritable permissions and OAuth2 grants..."); + logger.LogInformation("Configuring inheritable permissions..."); var inheritedPermissionsConfigured = false; Dictionary inheritedResults = @@ -148,9 +143,12 @@ internal static class BatchPermissionsOrchestrator // emitted and remaining specs are skipped. try { - inheritedResults = await ConfigureInheritedPermissionsAsync( - graph, blueprintService, blueprintAppId, tenantId, specs, - phase1Result, permScopes, logger, setupResults, ct); + using (logger.Indent()) + { + inheritedResults = await ConfigureInheritedPermissionsAsync( + graph, blueprintService, blueprintAppId, tenantId, specs, + phase1Result, permScopes, logger, setupResults, ct); + } var inheritableSpecs = specs.Where(s => s.SetInheritable).ToList(); inheritedPermissionsConfigured = inheritableSpecs.Count == 0 || @@ -180,7 +178,7 @@ internal static class BatchPermissionsOrchestrator logger.LogInformation(""); if (grantsOk) { - logger.LogInformation("Admin consent granted (tenant-wide grants configured in Phase 2)."); + logger.LogInformation("Admin consent granted."); UpdateResourceConsents(config, specs, inheritedResults); return (blueprintPermissionsUpdated, inheritedPermissionsConfigured, true, null); } @@ -263,8 +261,11 @@ private static async Task UpdateBlueprintPermissions { try { + // Suppress Graph POST warning: non-admin users cannot create SPs and that is expected. + // Phase 2 grants will be skipped for any resource whose SP cannot be resolved. var resourceSpId = await graph.EnsureServicePrincipalForAppIdAsync( - tenantId, spec.ResourceAppId, ct, permScopes); + tenantId, spec.ResourceAppId, ct, permScopes, + logWarningOnCreateFailure: false); if (!string.IsNullOrWhiteSpace(resourceSpId)) { @@ -273,7 +274,7 @@ private static async Task UpdateBlueprintPermissions } else { - logger.LogWarning( + logger.LogDebug( " - Service principal not found for {ResourceName} ({ResourceAppId}). " + "Phase 2 grants will be skipped for this resource.", spec.ResourceName, spec.ResourceAppId); @@ -281,7 +282,7 @@ private static async Task UpdateBlueprintPermissions } catch (Exception ex) { - logger.LogWarning( + logger.LogDebug( " - Failed to resolve service principal for {ResourceName}: {Message}. " + "Phase 2 grants will be skipped for this resource.", spec.ResourceName, ex.Message); @@ -351,13 +352,13 @@ private static async Task UpdateBlueprintPermissions { inheritedResults[spec.ResourceAppId] = (configured: true, alreadyExisted: alreadyExists); var verb = alreadyExists ? "already configured" : "configured"; - logger.LogInformation(" - {ResourceName}: inheritable permissions {Verb}", spec.ResourceName, verb); + logger.LogInformation("{ResourceName}: inheritable permissions {Verb}", spec.ResourceName, verb); } else { inheritedResults[spec.ResourceAppId] = (configured: false, alreadyExisted: false); logger.LogWarning( - " - Inheritable permissions set for {ResourceName} but verification read-back failed: {Error}", + "Inheritable permissions set for {ResourceName} but verification read-back failed: {Error}", spec.ResourceName, verifyErr ?? "not found in read-back"); setupResults?.Warnings.Add( $"Inheritable permissions for {spec.ResourceName} could not be verified after setting."); @@ -536,18 +537,16 @@ private static async Task PerformS2SGrantsAsync( .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); - // If there are no Graph scopes to consent to (e.g. agent config has no agentApplicationScopes), - // skip Phase 3 entirely — there is nothing to grant via the admin consent URL. - if (graphScopes.Count == 0) - { - logger.LogInformation("No Microsoft Graph scopes require admin consent — skipping consent URL."); - return (true, null); - } + // Build consent URL only when there are Graph scopes — non-Graph APIs cannot be consented + // via the /v2.0/adminconsent endpoint. They require Phase 2b (oauth2PermissionGrants via Graph API). + string? consentUrl = graphScopes.Count > 0 + ? SetupHelpers.BuildAdminConsentUrl(tenantId, blueprintAppId, graphScopes) + : null; - var consentUrl = SetupHelpers.BuildAdminConsentUrl(tenantId, blueprintAppId, graphScopes); - - // Check if consent already exists for ALL resolved resources (Phase 2 programmatic grants satisfy this check). - // Only skip browser consent if every resource has its consent in place. + // Check if consent already exists for ALL resolved resources (Phase 2b programmatic grants + // satisfy this check). Run this regardless of whether Graph scopes are present — non-DW + // blueprints have no Graph scopes but still require oauth2PermissionGrants for Observability + // and Power Platform APIs created by GA via Phase 2b or 'a365 setup admin'. if (phase1Result != null && !string.IsNullOrWhiteSpace(phase1Result.BlueprintSpObjectId)) { var specsWithResolvedSp = specs @@ -590,6 +589,14 @@ private static async Task PerformS2SGrantsAsync( } } + // Grants not fully in place. When there are no Graph scopes (non-DW path), there is no + // consent URL to open — the admin must run 'a365 setup admin' to create the oauth2PermissionGrants. + // No inline message: the caller surfaces this as an Action Required item in the summary. + if (graphScopes.Count == 0) + { + return (false, null); + } + // Consent not yet detected — check whether the current user can grant it interactively. // adminCheck was resolved before Phase 2 and passed in to avoid a duplicate Graph call. // When phase1Result is null, auth failed entirely — the message must reflect that, not imply @@ -612,7 +619,7 @@ private static async Task PerformS2SGrantsAsync( logger.LogInformation("Opening browser for Microsoft Graph admin consent..."); logger.LogInformation( "If the browser does not open automatically, navigate to this URL: {ConsentUrl}", consentUrl); - BrowserHelper.TryOpenUrl(consentUrl, logger); + BrowserHelper.TryOpenUrl(consentUrl!, logger); bool consentGranted; if (phase1Result != null && !string.IsNullOrWhiteSpace(phase1Result.BlueprintSpObjectId)) @@ -768,7 +775,7 @@ private record BlueprintPermissionsResult( // Phase 1: resolve SPs logger.LogInformation(""); - logger.LogInformation("Resolving service principals..."); + logger.LogInformation("Resolving service principals for permission configuration..."); // When delegated specs are empty but S2S specs exist, resolve SPs from S2S specs instead // so the blueprint SP object ID is available for app role assignment. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index 90d9ab7f..c8aae5fb 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -228,8 +228,9 @@ await RequirementsSubcommand.RunChecksOrExitAsync( } catch (Exception reqEx) when (reqEx is not OperationCanceledException && reqEx is not CleanExitException) { - logger.LogError(reqEx, "Requirements check failed with an unexpected error: {Message}", reqEx.Message); - logger.LogError("If you want to bypass requirement validation, rerun this command with the --skip-requirements flag."); + logger.LogError("Requirements check failed: {Message}", reqEx.Message); + logger.LogDebug(reqEx, "Requirements check exception details"); + logger.LogInformation("To bypass requirement validation, rerun with --skip-requirements."); ExceptionHandler.ExitWithCleanup(1); } } @@ -346,8 +347,7 @@ public static async Task CreateBlueprintImplementationA Func>? loginHintResolver = null) { logger.LogInformation(""); - logger.LogInformation("==> Creating Agent Blueprint"); - logger.LogInformation(""); + logger.LogInformation("Creating agent blueprint..."); var generatedConfigPath = Path.Combine( config.DirectoryName ?? Environment.CurrentDirectory, @@ -366,7 +366,7 @@ public static async Task CreateBlueprintImplementationA if (generatedConfig.TryGetPropertyValue("managedIdentityPrincipalId", out var existingPrincipalId)) { principalId = existingPrincipalId?.GetValue(); - logger.LogInformation("Found existing Managed Identity Principal ID: {Id}", principalId ?? "(none)"); + logger.LogDebug("Found existing Managed Identity Principal ID: {Id}", principalId ?? "(none)"); } } catch (Exception ex) @@ -376,13 +376,16 @@ public static async Task CreateBlueprintImplementationA } else { - logger.LogInformation("No existing configuration found - blueprint will be created without managed identity"); + logger.LogDebug("No existing configuration found - blueprint will be created without managed identity"); } - // Create required services + using var blueprintOuterScope = logger.Indent(); + + // Create required services. + // Pass the caller's logger so consent messages appear in the correct indent scope. var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); var delegatedConsentService = new DelegatedConsentService( - cleanLoggerFactory.CreateLogger(), + logger, new GraphApiService( cleanLoggerFactory.CreateLogger(), executor, @@ -452,12 +455,11 @@ public static async Task CreateBlueprintImplementationA if (!blueprintResult.success) { - logger.LogError("Failed to create agent blueprint"); - return new BlueprintCreationResult - { - BlueprintCreated = false, - EndpointRegistered = false, - EndpointRegistrationAttempted = false + return new BlueprintCreationResult + { + BlueprintCreated = false, + EndpointRegistered = false, + EndpointRegistrationAttempted = false }; } @@ -540,17 +542,9 @@ public static async Task CreateBlueprintImplementationA clientSecretManualActionRequired = !secretCreated; } + blueprintOuterScope.Dispose(); logger.LogInformation(""); - if (blueprintAlreadyExisted) - { - logger.LogInformation("Agent blueprint configured successfully"); - } - else - { - logger.LogInformation("Agent blueprint created successfully"); - } - logger.LogInformation("Generated config saved: {Path}", generatedConfigPath); - logger.LogInformation(""); + logger.LogDebug("Generated config saved: {Path}", generatedConfigPath); // Endpoint registration is temporarily disabled pending a backend fix. // Re-enable by restoring the registration block here and in the --endpoint-only / --update-endpoint @@ -584,17 +578,8 @@ await PermissionsSubcommand.ConfigureCustomPermissionsAsync( if (!isSetupAll) { logger.LogInformation("Next steps:"); - if (!endpointRegistered) - { - logger.LogInformation(" 1. Register endpoint: a365 setup blueprint --endpoint-only"); - logger.LogInformation(" 2. Run 'a365 setup permissions mcp' to configure MCP permissions"); - logger.LogInformation(" 3. Run 'a365 setup permissions bot' to configure Bot API permissions"); - } - else - { - logger.LogInformation(" 1. Run 'a365 setup permissions mcp' to configure MCP permissions"); - logger.LogInformation(" 2. Run 'a365 setup permissions bot' to configure Bot API permissions"); - } + logger.LogInformation(" 1. Run 'a365 setup permissions mcp' to configure MCP permissions"); + logger.LogInformation(" 2. Run 'a365 setup permissions bot' to configure Bot API permissions"); } return new BlueprintCreationResult @@ -627,15 +612,17 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( CancellationToken cancellationToken = default, string? correlationId = null) { - // Fast fail on invalid config — avoids multiple retry attempts with exponential backoff + // Fast fail on invalid config — these are configuration errors, not transient failures. + // Retrying would waste 35+ seconds with no chance of success. if (!Guid.TryParse(clientAppId, out _)) { - logger.LogError("Invalid Client App ID format: {AppId} — skipping consent", clientAppId ?? "(null)"); + logger.LogError("Invalid Client App ID format: {AppId}. Configure a valid GUID in a365.config.json.", clientAppId ?? "(null)"); return false; } + if (!Guid.TryParse(tenantId, out _)) { - logger.LogError("Invalid Tenant ID format: {TenantId} — skipping consent", tenantId ?? "(null)"); + logger.LogError("Invalid Tenant ID format: {TenantId}. Configure a valid GUID in a365.config.json.", tenantId ?? "(null)"); return false; } @@ -849,7 +836,8 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( // ======================================================================== try { - logger.LogInformation("Creating Agent Blueprint using Microsoft Graph SDK..."); + logger.LogInformation("Creating blueprint application..."); + using var blueprintAppScope = logger.Indent(); using GraphServiceClient graphClient = await GetAuthenticatedGraphClientAsync(logger, setupConfig, tenantId, ct); @@ -897,12 +885,14 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( var blueprintLoginHint = loginHintResolver != null ? await loginHintResolver() : await InteractiveGraphAuthService.ResolveAzLoginHintAsync(); - // Use Application.ReadWrite.All explicitly — NOT .default. Using .default bundles all - // consented scopes including AgentIdentityBlueprint.*, which Entra rejects for - // POST /v1.0/servicePrincipals ("backing application must be in the local tenant"). - logger.LogDebug("Acquiring blueprint httpClient token — scope: Application.ReadWrite.All, loginHint: {LoginHint}", blueprintLoginHint ?? "(none)"); + // Explicit scopes — NOT .default. Using .default bundles all consented scopes including + // AgentIdentityBlueprint.*, which Entra rejects for POST /v1.0/servicePrincipals + // ("backing application must be in the local tenant"). + // AgentIdentityBlueprintPrincipal.Create is the correct scope per Agent ID team (Kyle Marsh). + logger.LogDebug("Acquiring blueprint httpClient token — scope: AgentIdentityBlueprintPrincipal.Create, loginHint: {LoginHint}", blueprintLoginHint ?? "(none)"); var graphToken = await AcquireMsalGraphTokenAsync(tenantId, setupConfig.ClientAppId, logger, ct, - scope: AuthenticationConstants.ApplicationReadWriteAllScope, loginHint: blueprintLoginHint); + scope: AuthenticationConstants.AgentIdentityBlueprintPrincipalCreateScope, + loginHint: blueprintLoginHint); if (string.IsNullOrEmpty(graphToken)) { logger.LogError("Failed to extract access token from Graph client"); @@ -916,11 +906,10 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( var createAppUrl = $"{Constants.GraphApiConstants.BaseUrl}/beta/applications"; - logger.LogInformation("Creating Agent Blueprint application..."); - logger.LogInformation(" - Display Name: {DisplayName}", displayName); + logger.LogInformation("Display Name: {DisplayName}", displayName); if (!string.IsNullOrEmpty(sponsorUserId)) { - logger.LogInformation(" - Sponsor and Owner: User ID {UserId}", sponsorUserId); + logger.LogInformation("Sponsor and Owner: User ID {UserId}", sponsorUserId); } var appResponse = await httpClient.PostAsync( @@ -970,7 +959,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null, ficConfigured: false, ficError: null, adminConsentUrl: null); } - logger.LogWarning("Agent Blueprint created without owner assignment. Client secret creation will fail unless the custom client app has Application.ReadWrite.All permission or you have Application Administrator role in your Entra tenant."); + logger.LogWarning("Agent Blueprint created without owner assignment. Client secret creation may fail — ensure you have Application Administrator role or the blueprint owner is set correctly."); } else { @@ -994,9 +983,13 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( var appId = app["appId"]!.GetValue(); var objectId = app["id"]!.GetValue(); - logger.LogInformation("Application created successfully"); - logger.LogInformation(" Blueprint ID: {AppId}", appId); - logger.LogDebug(" Object ID: {ObjectId}", objectId); + blueprintAppScope.Dispose(); + logger.LogInformation("Blueprint application created successfully"); + using (logger.Indent()) + { + logger.LogInformation("Blueprint ID: {AppId}", appId); + logger.LogDebug("Object ID: {ObjectId}", objectId); + } // Wait for application propagation using RetryHelper var retryHelper = new RetryHelper(logger); @@ -1020,12 +1013,28 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( logger.LogDebug("Application object verified in directory"); - // Update application with identifier URI + // Update application with identifier URI and expose the access_agent_as_user scope + // so callers can acquire tokens scoped to this blueprint via the OBO flow. var identifierUri = $"api://{appId}"; var patchAppUrl = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/applications/{objectId}"; var patchBody = new JsonObject { - ["identifierUris"] = new JsonArray { identifierUri } + ["identifierUris"] = new JsonArray { identifierUri }, + ["api"] = new JsonObject + { + ["oauth2PermissionScopes"] = new JsonArray + { + new JsonObject + { + ["adminConsentDescription"] = "Allow the agent to act on behalf of the signed-in user.", + ["adminConsentDisplayName"] = "Access agent on behalf of user", + ["id"] = Guid.NewGuid().ToString(), + ["isEnabled"] = true, + ["type"] = "User", + ["value"] = Constants.ConfigConstants.BlueprintOboScope + } + } + } }; var patchResponse = await httpClient.PatchAsync( @@ -1037,28 +1046,34 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { var patchError = await patchResponse.Content.ReadAsStringAsync(ct); logger.LogDebug("Waiting for application propagation before setting identifier URI..."); - logger.LogDebug("Identifier URI update deferred (propagation delay): {Error}", patchError); + logger.LogDebug("Identifier URI / scope update deferred (propagation delay): {Error}", patchError); } else { - logger.LogDebug("Identifier URI set to: {Uri}", identifierUri); + logger.LogDebug("Identifier URI set to {Uri}; {Scope} scope added", identifierUri, Constants.ConfigConstants.BlueprintOboScope); } // Create service principal // Retry on 400 NoBackingApplicationObject: Agent Blueprint apps may not yet be indexed // by appId in all Graph API replicas even after the application object is visible by // objectId. Retry with backoff until the appId index is replicated. - logger.LogInformation("Creating service principal..."); + logger.LogInformation(""); + logger.LogInformation("Creating blueprint service principal..."); string? servicePrincipalId = await CreateServicePrincipalAsync(appId, httpClient, retryHelper, logger, ct); if (string.IsNullOrWhiteSpace(servicePrincipalId)) { logger.LogError("Service principal creation failed after retries"); } + else + { + using (logger.Indent()) + logger.LogInformation("Blueprint service principal ID: {SpId}", servicePrincipalId); + } // Wait for service principal propagation using RetryHelper if (!string.IsNullOrWhiteSpace(servicePrincipalId)) { - logger.LogInformation("Verifying service principal propagation in directory..."); + logger.LogDebug("Verifying blueprint service principal..."); var spPropagated = await retryHelper.ExecuteWithRetryAsync( async ct => { @@ -1121,7 +1136,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( } catch (Exception ex) { - logger.LogError(ex, "Failed to create agent blueprint: {Message}", ex.Message); + logger.LogDebug(ex, "Blueprint creation failed: {Message}", ex.Message); return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null, ficConfigured: false, ficError: null, adminConsentUrl: null); } } @@ -1300,10 +1315,18 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( logger.LogDebug("Skipping owner validation for existing blueprint (owners@odata.bind not applied to existing blueprints)"); } + // ======================================================================== + // OBO Scope Reconciliation + // Ensure the blueprint exposes access_agent_as_user. Idempotent — skipped + // when the scope is already present. Runs for both new and existing blueprints + // so that re-runs of setup patch blueprints created before this feature. + // ======================================================================== + await EnsureOboScopeAsync(graphApiService, tenantId, objectId, logger, ct); + // ======================================================================== // Federated Identity Credential Validation/Creation // ======================================================================== - + // Create Federated Identity Credential ONLY when MSI is relevant (if managed identity provided) bool ficConfigured = false; string? ficError = null; @@ -1416,6 +1439,82 @@ await retryHelper.ExecuteWithRetryAsync( return (true, appId, objectId, servicePrincipalId, alreadyExisted, consentSuccess, graphPermissionsFailed, graphInheritablePermissionsError, ficConfigured, ficError, adminConsentUrl); } + /// + /// Ensures the blueprint application exposes the + /// delegated scope. Idempotent — no-op when the scope already exists. + /// Preserves any other scopes already configured on the application. + /// + private static async Task EnsureOboScopeAsync( + GraphApiService graphApiService, + string tenantId, + string objectId, + ILogger logger, + CancellationToken ct) + { + try + { + using var appDoc = await graphApiService.GraphGetAsync( + tenantId, $"/v1.0/applications/{objectId}?$select=api", ct, + scopes: AuthenticationConstants.RequiredClientAppPermissions); + + var existingScopes = new JsonArray(); + + if (appDoc != null && + appDoc.RootElement.TryGetProperty("api", out var apiProp) && + apiProp.TryGetProperty("oauth2PermissionScopes", out var scopesEl)) + { + foreach (var scope in scopesEl.EnumerateArray()) + { + if (scope.TryGetProperty("value", out var val) && + string.Equals(val.GetString(), ConfigConstants.BlueprintOboScope, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug("Blueprint already has {Scope} scope — skipping", ConfigConstants.BlueprintOboScope); + return; + } + + // Preserve existing scope in the PATCH body so we don't overwrite it. + existingScopes.Add(JsonNode.Parse(scope.GetRawText())); + } + } + + logger.LogInformation("Adding {Scope} scope to blueprint...", ConfigConstants.BlueprintOboScope); + + existingScopes.Add(new JsonObject + { + ["adminConsentDescription"] = "Allow the agent to act on behalf of the signed-in user.", + ["adminConsentDisplayName"] = "Access agent on behalf of user", + ["id"] = Guid.NewGuid().ToString(), + ["isEnabled"] = true, + ["type"] = "User", + ["value"] = ConfigConstants.BlueprintOboScope + }); + + var patch = new JsonObject + { + ["api"] = new JsonObject + { + ["oauth2PermissionScopes"] = existingScopes + } + }; + + var patched = await graphApiService.GraphPatchAsync( + tenantId, $"/v1.0/applications/{objectId}", patch, ct, + scopes: AuthenticationConstants.RequiredClientAppPermissions); + + if (patched) + logger.LogInformation("{Scope} scope added to blueprint", ConfigConstants.BlueprintOboScope); + else + logger.LogWarning( + "Could not add {Scope} scope to blueprint. Add it manually: " + + "Entra portal > App registrations > Expose an API.", + ConfigConstants.BlueprintOboScope); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning("OBO scope reconciliation failed (non-fatal): {Message}", ex.Message); + } + } + /// /// Gets application scopes from config with fallback to defaults. /// @@ -1673,7 +1772,7 @@ await SetupHelpers.EnsureResourcePermissionsAsync( /// rejected by the Agent Blueprint API. Defaults to .default (all consented permissions). /// Pass loginHint so WAM targets the az-logged-in user rather than the OS default account. /// - private static async Task AcquireMsalGraphTokenAsync(string tenantId, string clientAppId, ILogger logger, CancellationToken ct = default, string? scope = null, string? loginHint = null) + private static async Task AcquireMsalGraphTokenAsync(string tenantId, string clientAppId, ILogger logger, CancellationToken ct = default, string? scope = null, string? loginHint = null, string[]? additionalScopes = null) { // Guard: MSAL will fail (and block for ~30s on WAM) with empty credentials. if (string.IsNullOrWhiteSpace(clientAppId) || string.IsNullOrWhiteSpace(tenantId)) @@ -1691,12 +1790,20 @@ await SetupHelpers.EnsureResourcePermissionsAsync( logger, loginHint: loginHint); - var resolvedScope = string.IsNullOrWhiteSpace(scope) + var primaryScope = string.IsNullOrWhiteSpace(scope) ? $"{Constants.GraphApiConstants.BaseUrl}/.default" : $"{Constants.GraphApiConstants.BaseUrl}/{scope}"; - var tokenRequestContext = new TokenRequestContext(new[] { resolvedScope }); + + var allScopes = additionalScopes?.Length > 0 + ? new[] { primaryScope }.Concat(additionalScopes.Select(s => $"{Constants.GraphApiConstants.BaseUrl}/{s}")).ToArray() + : new[] { primaryScope }; + + var tokenRequestContext = new TokenRequestContext(allScopes); var token = await credential.GetTokenAsync(tokenRequestContext, ct); + logger.LogDebug("Acquired MSAL token (requested: [{Scopes}])", string.Join(", ", allScopes)); + TryLogTokenScp(token.Token, logger); + return token.Token; } catch (Exception ex) when (ex is not OperationCanceledException) @@ -1706,38 +1813,59 @@ await SetupHelpers.EnsureResourcePermissionsAsync( } } + /// + /// Decodes the JWT payload and logs the scp claim at Debug level. + /// Used only to diagnose scope issues during blueprint creation. + /// + private static void TryLogTokenScp(string token, ILogger logger) + { + try + { + var parts = token.Split('.'); + if (parts.Length < 2) return; + var payload = parts[1].Replace('-', '+').Replace('_', '/'); + payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '='); + var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload)); + using var doc = System.Text.Json.JsonDocument.Parse(json); + var scp = doc.RootElement.TryGetProperty("scp", out var scpEl) ? scpEl.GetString() : "(absent)"; + var upn = doc.RootElement.TryGetProperty("upn", out var upnEl) ? upnEl.GetString() + : doc.RootElement.TryGetProperty("unique_name", out var unEl) ? unEl.GetString() : "(absent)"; + logger.LogDebug("Token scp: {Scp} | upn: {Upn}", scp, upn); + } + catch { /* non-fatal */ } + } + /// /// Creates and authenticates a GraphServiceClient using InteractiveGraphAuthService. /// This common method consolidates the authentication logic used across multiple methods. /// private async static Task GetAuthenticatedGraphClientAsync(ILogger logger, Models.Agent365Config setupConfig, string tenantId, CancellationToken ct) { - logger.LogInformation("Authenticating to Microsoft Graph using interactive browser authentication..."); - logger.LogInformation("IMPORTANT: Agent Blueprint operations require Application.ReadWrite.All permission."); - logger.LogInformation("This will open a browser window for interactive authentication."); - logger.LogInformation("Please sign in with your Microsoft account."); - logger.LogInformation(""); + logger.LogInformation("Sign in to Microsoft Graph to continue..."); - // Use InteractiveGraphAuthService to get proper authentication - using var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); + // Use InteractiveGraphAuthService to get proper authentication. + // Pass the caller's logger so messages appear in the correct indent scope. var interactiveAuth = new InteractiveGraphAuthService( - cleanLoggerFactory.CreateLogger(), + logger, setupConfig.ClientAppId); try { var graphClient = await interactiveAuth.GetAuthenticatedGraphClientAsync(tenantId, ct); - logger.LogInformation("Successfully authenticated to Microsoft Graph"); return graphClient; } catch (Exception ex) { - logger.LogError(ex, "Failed to authenticate to Microsoft Graph: {Message}", ex.Message); - logger.LogError(""); - logger.LogError("TROUBLESHOOTING:"); - logger.LogError("1. Ensure you are a Global Administrator or have Application.ReadWrite.All permission"); - logger.LogError("2. The account must have already consented to these permissions"); - logger.LogError(""); + var isCanceled = ex.Message.Contains("cancel", StringComparison.OrdinalIgnoreCase); + if (!isCanceled) + { + logger.LogError("Failed to authenticate to Microsoft Graph: {Message}", ex.Message); + logger.LogError(""); + logger.LogError("TROUBLESHOOTING:"); + logger.LogError("1. Ensure you are a Global Administrator or have AgentIdentityBlueprint.ReadWrite.All permission"); + logger.LogError("2. The account must have already consented to these permissions"); + logger.LogError(""); + } throw new InvalidOperationException($"Microsoft Graph authentication failed: {ex.Message}", ex); } } @@ -1758,10 +1886,11 @@ public static async Task CreateBlueprintClientSecretAsync( CancellationToken ct = default, Func>? loginHintResolver = null) { + logger.LogInformation(""); + logger.LogInformation("Creating blueprint client secret..."); + using var clientSecretScope = logger.Indent(); try { - logger.LogInformation("Creating client secret for Agent Blueprint using Graph API..."); - // Resolve login hint so WAM targets the az-logged-in user, not the OS default account. // Without this, WAM may return a cached token for a different user who is not the owner. var loginHint = loginHintResolver != null @@ -1798,15 +1927,33 @@ public static async Task CreateBlueprintClientSecretAsync( var addPasswordUrl = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/applications/{blueprintObjectId}/addPassword"; var secretBodyJson = secretBody.ToJsonString(); - // Retry on 404: newly created Agent Blueprints may not yet be visible to all Graph - // API replicas due to Entra eventual consistency. Retry with backoff until propagated. + + // Retry on 404 (blueprint not yet visible on all replicas) and transient 403 (owner + // propagation lag — the blueprint was just created with owners@odata.bind, and Entra + // may not yet recognize the caller as owner when addPassword is called immediately after + // creation). Do NOT retry on Authorization_RequestDenied (permanent permission failure). var retryHelper = new RetryHelper(logger); var passwordResponse = await retryHelper.ExecuteWithRetryAsync( async token => await httpClient.PostAsync( addPasswordUrl, new StringContent(secretBodyJson, System.Text.Encoding.UTF8, "application/json"), token), - response => response.StatusCode == System.Net.HttpStatusCode.NotFound, + async (response, token) => + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + return true; + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + // Buffer so the body can be re-read by the caller after this predicate. + await response.Content.LoadIntoBufferAsync(); + var body = await response.Content.ReadAsStringAsync(token); + // Authorization_RequestDenied = permanent privilege failure — no point retrying. + if (body.Contains("Authorization_RequestDenied", StringComparison.OrdinalIgnoreCase)) + return false; + return true; // transient 403 (owner propagation lag), retry + } + return false; + }, maxRetries: 5, baseDelaySeconds: 5, cancellationToken: ct); @@ -1853,7 +2000,8 @@ public static async Task CreateBlueprintClientSecretAsync( } catch (Exception ex) { - logger.LogWarning(ex, "Failed to create client secret automatically: {Message}", ex.Message); + logger.LogDebug(ex, "Failed to create blueprint client secret (detail)"); + logger.LogWarning("Insufficient privileges to create blueprint client secret automatically. You must create it manually."); logger.LogWarning("Create the client secret manually for blueprint app {AppId} and add it to a365.generated.config.json, then re-run: a365 setup all", blueprintAppId); logger.LogWarning("See: https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials"); return false; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs index 75e5baf9..89739d80 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs @@ -85,13 +85,13 @@ public static Command CreateCommand( logger.LogInformation(" - Managed Service Identity: Enabled"); // Detect platform (even in dry-run for informational purposes) - if (!string.IsNullOrWhiteSpace(dryRunConfig.DeploymentProjectPath)) - { - var detectedPlatform = platformDetector.Detect(dryRunConfig.DeploymentProjectPath); - var detectedRuntime = await GetLinuxFxVersionForPlatformAsync(detectedPlatform, dryRunConfig.DeploymentProjectPath, executor, logger); - logger.LogInformation(" - Detected Platform: {Platform}", detectedPlatform); - logger.LogInformation(" - Runtime: {Runtime}", detectedRuntime); - } + var dryRunProjectPath = string.IsNullOrWhiteSpace(dryRunConfig.DeploymentProjectPath) + ? Environment.CurrentDirectory + : dryRunConfig.DeploymentProjectPath; + var detectedPlatform = platformDetector.Detect(dryRunProjectPath); + var detectedRuntime = await GetLinuxFxVersionForPlatformAsync(detectedPlatform, dryRunProjectPath, executor, logger); + logger.LogInformation(" - Detected Platform: {Platform}", detectedPlatform); + logger.LogInformation(" - Runtime: {Runtime}", detectedRuntime); return; } @@ -189,56 +189,41 @@ await CreateInfrastructureImplementationAsync( return (false, false); } } - else - { - // Non-Azure hosting or --blueprint: no infra required - if (string.IsNullOrWhiteSpace(subscriptionId)) - { - logger.LogWarning( - "subscriptionId is not set. This is acceptable for blueprint-only or External hosting mode " + - "as Azure infrastructure will not be provisioned."); - } - } // Detect project platform for appropriate runtime configuration - var platform = Models.ProjectPlatform.DotNet; // Default fallback - if (!string.IsNullOrWhiteSpace(deploymentProjectPath)) - { - platform = platformDetector.Detect(deploymentProjectPath); - logger.LogInformation("Detected project platform: {Platform}", platform); - } - else - { - logger.LogWarning("No deploymentProjectPath specified, defaulting to .NET runtime"); - } - logger.LogInformation(""); - - logger.LogInformation("Agent 365 Setup Infrastructure - Starting..."); - logger.LogInformation("Subscription: {Sub}", subscriptionId); - logger.LogInformation("Resource Group: {RG}", resourceGroup); - logger.LogInformation("App Service Plan: {Plan}", planName); - logger.LogInformation("Web App: {App}", webAppName); - logger.LogInformation("Location: {Loc}", location); + var effectiveProjectPath = string.IsNullOrWhiteSpace(deploymentProjectPath) + ? Environment.CurrentDirectory + : deploymentProjectPath; + var platform = platformDetector.Detect(effectiveProjectPath); + logger.LogInformation("Detected project platform: {Platform}", platform); logger.LogInformation(""); if (!skipInfra) { + logger.LogInformation("Agent 365 Setup Infrastructure - Starting..."); + using (logger.Indent()) + { + logger.LogInformation("Subscription: {Sub}", subscriptionId); + logger.LogInformation("Resource Group: {RG}", resourceGroup); + if (!string.IsNullOrWhiteSpace(planName)) + logger.LogInformation("App Service Plan: {Plan}", planName); + if (!string.IsNullOrWhiteSpace(webAppName)) + logger.LogInformation("Web App: {App}", webAppName); + logger.LogInformation("Location: {Loc}", location); + } + logger.LogInformation(""); + bool isValidated = await ValidateAzureCliAuthenticationAsync( - commandExecutor, - tenantId, - logger, - cancellationToken); + commandExecutor, + tenantId, + logger, + cancellationToken); if (!isValidated) { return (false, false); } } - else - { - logger.LogInformation("==> Skipping Azure management authentication (--skipInfrastructure or External hosting)"); - logger.LogInformation(""); - } var (principalId, anyAlreadyExisted) = await CreateInfrastructureAsync( commandExecutor, @@ -250,7 +235,7 @@ await CreateInfrastructureImplementationAsync( planSku, webAppName, generatedConfigPath, - deploymentProjectPath, + effectiveProjectPath, platform, logger, needDeployment, @@ -272,8 +257,7 @@ public static async Task ValidateAzureCliAuthenticationAsync( ILogger logger, CancellationToken cancellationToken = default) { - logger.LogInformation("==> Verifying Azure CLI authentication"); - logger.LogInformation(""); + logger.LogInformation("Verifying Azure CLI authentication..."); // Use cached login hint from AzCliHelper (populated by requirements check). // Falls back to spawning 'az account show' only on first call in this process. @@ -339,11 +323,8 @@ public static async Task ValidateAzureCliAuthenticationAsync( if (skipInfra) { - var modeMessage = "External hosting (non-Azure)"; - - logger.LogInformation("==> Skipping Azure infrastructure ({Mode})", modeMessage); - logger.LogInformation(""); - logger.LogInformation("Loading existing configuration..."); + logger.LogInformation("Skipping infrastructure setup — no Azure deployment configured."); + logger.LogDebug("Loading existing configuration..."); // Load existing generated config if available if (File.Exists(generatedConfigPath)) @@ -356,7 +337,7 @@ public static async Task ValidateAzureCliAuthenticationAsync( { // Only reuse MSI in blueprint-only mode principalId = existingPrincipalId?.GetValue(); - logger.LogInformation("Found existing Managed Identity Principal ID: {Id}", principalId ?? "(none)"); + logger.LogDebug("Found existing Managed Identity Principal ID: {Id}", principalId ?? "(none)"); } else if (externalHosting) { @@ -366,25 +347,18 @@ public static async Task ValidateAzureCliAuthenticationAsync( principalId = null; } - logger.LogInformation("Existing configuration loaded successfully"); + logger.LogDebug("Existing configuration loaded successfully"); } catch (Exception ex) { logger.LogWarning("Could not load existing config: {Message}. Starting fresh.", ex.Message); } } - else - { - logger.LogInformation("No existing configuration found - blueprint will be created without managed identity"); - } - - logger.LogInformation(""); return (principalId, false); // Skip infra means nothing was created/modified } else { - logger.LogInformation("==> Deploying App Service + enabling Managed Identity"); - logger.LogInformation(""); + logger.LogInformation("Deploying App Service and enabling Managed Identity..."); // Resource group // Use ArmApiService for a direct HTTP check (~0.5s) instead of az subprocess (~15-20s). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs new file mode 100644 index 00000000..d5a0db8c --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs @@ -0,0 +1,531 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Microsoft.Agents.A365.DevTools.Cli.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// Orchestrates setup for blueprint-based non-AI Teammate agent deployments. +/// Runs the same steps as DW (infrastructure, blueprint, permissions) then appends +/// two non-DW-only steps: Agent Identity creation and agent registration. +/// +/// Steps: +/// 1. Requirements validation +/// 2. Blueprint creation (shared with DW) +/// 3. Batch permissions (shared with DW — dynamic scopes from config) +/// 4. Agent Identity creation via POST /beta/servicePrincipals/Microsoft.Graph.AgentIdentity +/// 5. Agent registration via Graph API (copilot/agentRegistrations) +/// +internal static class NonDwBlueprintSetupOrchestrator +{ + + /// + /// Prints a dry-run plan showing all resources that would be created or configured, + /// using actual names and values from the loaded config. Makes no API calls. + /// + public static void PrintDryRunPlan(Agent365Config config, ILogger logger, bool isBootstrap = false, string[]? rawArgs = null, bool skipRequirements = false) + { + var sub = new string(' ', SetupHelpers.DryRunValCol); + + // Use explicitly-passed tokens when available; fall back to a known-correct default. + // Environment.GetCommandLineArgs() is unreliable in dotnet tool / test hosting scenarios. + var cmdArgs = rawArgs is { Length: > 0 } + ? string.Join(" ", rawArgs.Where(a => !a.Equals("--dry-run", StringComparison.OrdinalIgnoreCase))) + : "setup all"; + logger.LogInformation("Dry run: a365 {Args} --dry-run", cmdArgs); + logger.LogInformation(""); + logger.LogInformation("The following steps would be performed."); + logger.LogInformation(""); + + // 1. Prerequisites + if (skipRequirements) + logger.LogInformation(SetupHelpers.DryRunRow(1, "Prerequisites") + "skip (--skip-requirements)"); + else if (isBootstrap) + logger.LogInformation(SetupHelpers.DryRunRow(1, "Prerequisites") + "validate (Azure CLI, PowerShell modules)"); + else + logger.LogInformation(SetupHelpers.DryRunRow(1, "Prerequisites") + "validate (PowerShell modules, Azure CLI, client app)"); + + // 2. Blueprint + var blueprintDisplayName = config.AgentBlueprintDisplayName ?? config.AgentIdentityDisplayName ?? "Agent Blueprint"; + var blueprintExists = !string.IsNullOrWhiteSpace(config.AgentBlueprintId); + if (blueprintExists) + { + SetupHelpers.PrintDryRunBlueprintReuseRows(logger, config.AgentBlueprintId!, step: 2); + } + else + { + logger.LogInformation(SetupHelpers.DryRunRow(2, "Blueprint") + "create (multi-tenant): {DisplayName}", blueprintDisplayName); + logger.LogInformation(sub + "create service principal"); + logger.LogInformation(sub + "create client secret"); + logger.LogInformation(sub + "create federated identity credential (FIC)"); + logger.LogInformation(sub + "create managed identity"); + } + + // 3. Inheritable Permissions + var permsList = new List { "Observability API", "Power Platform API" }; + if (config.CustomBlueprintPermissions?.Count > 0) + foreach (var custom in config.CustomBlueprintPermissions) + permsList.Add(custom.ResourceName ?? custom.ResourceAppId); + logger.LogInformation(SetupHelpers.DryRunRow(3, "Inheritable Permissions") + "configure for {Permissions}", string.Join(", ", permsList)); + + // 4. Permission Grants + var blueprintIdForCmd = config.AgentBlueprintId ?? ""; + logger.LogInformation(SetupHelpers.DryRunRow(4, "Permission Grants") + "admin approval required — a365 setup admin --blueprint-id {BlueprintId}", blueprintIdForCmd); + + // 5. Agent identity + var identityDisplayName = config.AgentIdentityDisplayName ?? "Agent"; + var registrationDisplayName = identityDisplayName.EndsWith(" Identity", StringComparison.OrdinalIgnoreCase) + ? identityDisplayName[..^" Identity".Length].TrimEnd() + " Agent" + : identityDisplayName; + if (!string.IsNullOrWhiteSpace(config.AgenticAppId)) + logger.LogInformation(SetupHelpers.DryRunRow(5, "Agent identity") + "reuse: {DisplayName} (ID: {AgentId})", identityDisplayName, config.AgenticAppId); + else + logger.LogInformation(SetupHelpers.DryRunRow(5, "Agent identity") + "create: {DisplayName}", identityDisplayName); + + // 6. Agent Registration + if (!string.IsNullOrWhiteSpace(config.AgentRegistrationId)) + logger.LogInformation(SetupHelpers.DryRunRow(6, "Agent Registration") + "reuse: {DisplayName} (ID: {RegistrationId})", registrationDisplayName, config.AgentRegistrationId); + else + logger.LogInformation(SetupHelpers.DryRunRow(6, "Agent Registration") + "register: {DisplayName}", registrationDisplayName); + + // 7. Project settings + logger.LogInformation(SetupHelpers.DryRunRow(7, "Project settings") + "write to appsettings.json"); + + logger.LogInformation(""); + logger.LogInformation("No changes will be made. Run without --dry-run to apply."); + } + + /// + /// Checks whether any required CLI app permissions are missing from the tenant's consent grant. + /// If so, lists them, asks the user for confirmation, and grants consent if confirmed. + /// Skipped when ClientAppId is not configured (consent is not applicable). + /// + private static async Task EnsureConsentWithPromptAsync(SetupContext ctx) + { + var clientAppId = ctx.Config.ClientAppId; + var tenantId = ctx.Config.TenantId; + + if (string.IsNullOrWhiteSpace(clientAppId) || string.IsNullOrWhiteSpace(tenantId)) + return; + + List unconsented; + try + { + unconsented = await ctx.ClientAppValidator.GetUnconsentedRequiredPermissionsAsync( + clientAppId, tenantId, ctx.CancellationToken); + } + catch (Exception ex) + { + ctx.Logger.LogDebug(ex, "Could not check consent status (non-fatal): {Message}", ex.Message); + return; + } + + if (unconsented is null || unconsented.Count == 0) + return; + + ctx.Logger.LogInformation(""); + ctx.Logger.LogInformation("The following required permissions are not yet consented for your client app ({ClientAppId}):", clientAppId); + foreach (var p in unconsented) + ctx.Logger.LogInformation(" - {Permission}", p); + + ctx.Logger.LogInformation(""); + var confirmed = await ctx.ConfirmationProvider.ConfirmAsync("Grant admin consent for these permissions now? [y/N]: "); + ctx.CancellationToken.ThrowIfCancellationRequested(); + + if (!confirmed) + { + ctx.Logger.LogWarning("Admin consent not granted. Setup may fail if these permissions are required."); + return; + } + + ctx.Logger.LogInformation("Granting admin consent..."); + try + { + await ctx.ClientAppValidator.GrantConsentForPermissionsAsync( + clientAppId, unconsented, tenantId, ctx.CancellationToken); + ctx.Logger.LogInformation("Admin consent granted for: {Permissions}", string.Join(", ", unconsented)); + } + catch (Exception ex) + { + ctx.Logger.LogWarning(ex, "Could not grant admin consent (non-fatal): {Message}", ex.Message); + } + } + + /// + /// Executes the full non-DW blueprint setup: + /// 1. Requirements validation + /// 2. Blueprint creation (shared with DW) + /// 3. Batch permissions (Graph + A365 Tools only) + /// 4. Agent Instance registration + /// + /// Exit code: 0 on success, 1 on fatal failure. + public static async Task ExecuteAsync(SetupContext ctx) + { + ctx.Results.IsNonDwBlueprintFlow = true; + // Bootstrap already printed the "Running..." banner before auth steps; skip here to avoid duplication. + if (!ctx.IsBootstrap) + { + ctx.Logger.LogInformation("Running \"a365 {Args}\"...", string.Join(" ", Environment.GetCommandLineArgs().Skip(1))); + ctx.Logger.LogInformation(""); + } + ctx.Logger.LogDebug("TraceId: {TraceId}", ctx.CorrelationId); + + List specs = []; + + try + { + if (ctx.AgentInstanceOnly) + { + ctx.Logger.LogInformation("NOTE: --agent-instance-only flag set. Skipping requirements, blueprint, and permissions steps."); + ctx.Logger.LogInformation(""); + // Populate results so the summary shows previous steps as already completed + ctx.Results.BlueprintCreated = true; + ctx.Results.BlueprintAlreadyExisted = true; + ctx.Results.BlueprintId = ctx.Config.AgentBlueprintId; + ctx.Results.BlueprintDisplayName = ctx.Config.AgentBlueprintDisplayName; + ctx.Results.BatchPermissionsPhase2Completed = true; + ctx.Results.AdminConsentGranted = true; + // Still check and prompt for consent even when skipping other steps — consent + // is required for the registration call and may have been missed in a prior run. + await EnsureConsentWithPromptAsync(ctx); + await ExecuteAgentIdentityAndRegistrationAsync(ctx, specs); + } + else + { + ctx.Results.PrerequisitesSkipped = ctx.SkipRequirements; + ctx.Results.InfrastructureSkipped = !ctx.Config.NeedDeployment || ctx.SkipInfrastructure; + + // Step 1: Requirements validation + if (!ctx.SkipRequirements) + { + var includeInfra = !ctx.SkipInfrastructure && ctx.Config.NeedDeployment; + var checks = AllSubcommand.GetNonDwChecks(ctx.AuthValidator, ctx.ClientAppValidator, includeInfra, isBootstrap: ctx.IsBootstrap); + try + { + await RequirementsSubcommand.RunChecksOrExitAsync(checks, ctx.Config, ctx.Logger, ctx.CancellationToken); + } + catch (Exception reqEx) when (reqEx is not OperationCanceledException && reqEx is not CleanExitException) + { + ctx.Logger.LogError("Requirements check failed: {Message}", reqEx.Message); + ctx.Logger.LogDebug(reqEx, "Requirements check exception details"); + ctx.Logger.LogInformation("To bypass requirement validation, rerun with --skip-requirements."); + return 1; + } + } + else + { + ctx.Logger.LogInformation("Requirements validation skipped (--skip-requirements flag used)"); + } + + // Step 1.5: Consent check — detect missing consent for required permissions and prompt. + await EnsureConsentWithPromptAsync(ctx); + + // Step 2: Infrastructure (shared with DW, skipped when NeedDeployment=false or --skip-infrastructure) + await AllSubcommand.ExecuteInfrastructureStepAsync(ctx); + + // Step 3: Blueprint creation (shared with DW) + await AllSubcommand.ExecuteBlueprintStepAsync(ctx); + + // Step 4: Batch permissions — non-DW path stamps only Observability API and Power Platform API. + // Microsoft Graph, Agent 365 Tools (MCP), and Messaging Bot API are excluded. + var buildResult = await AllSubcommand.BuildPermissionSpecsAsync(ctx, isDw: false); + specs = buildResult.specs; + var mcpResourceAppId = buildResult.mcpResourceAppId; + + await AllSubcommand.ExecuteBatchPermissionsStepAsync(ctx, specs); + + SetupHelpers.ApplyConsentUrlsIfNeeded(ctx, mcpResourceAppId, graphScopes: [], mcpScopes: [], isDw: false); + + // Save state after permissions (before agent identity creation, so progress + // is not lost if subsequent steps fail). + await ctx.ConfigService.SaveStateAsync(ctx.Config); + + // Steps 5-8: Agent identity creation, permission grants, registration, project settings. + await ExecuteAgentIdentityAndRegistrationAsync(ctx, specs); + } + } + catch (Agent365Exception ex) + { + var logFilePath = Services.ConfigService.GetCommandLogPath(Constants.CommandNames.Setup); + Exceptions.ExceptionHandler.HandleAgent365Exception(ex, logFilePath: logFilePath); + ctx.Results.Errors.Add(ex.Message); + } + catch (FileNotFoundException fnfEx) + { + ctx.Logger.LogError("Setup failed: {Message}", fnfEx.Message); + ctx.Results.Errors.Add(fnfEx.Message); + } + catch (OperationCanceledException) + { + ctx.Logger.LogInformation(""); + ctx.Logger.LogInformation("Setup cancelled."); + return 1; + } + catch (Exception ex) + { + ctx.Logger.LogError(ex, "Setup failed: {Message}", ex.Message); + ctx.Results.Errors.Add(ex.Message); + } + + // Display summary — always, even when errors occurred above + ctx.Logger.LogInformation(""); + SetupHelpers.DisplaySetupSummary(ctx.Results, ctx.Logger, isDw: false); + + return ctx.Results.HasErrors ? 1 : 0; + } + + /// + /// Executes Steps 5-8: agent identity creation, permission grants, agent registration, + /// and project settings sync. Called from both the normal path and the --agent-instance-only + /// shortcut path. + /// + private static async Task ExecuteAgentIdentityAndRegistrationAsync( + SetupContext ctx, + List specs) + { + // Step 5: Create Agent Identity via Agent Identity Graph API. + ctx.Logger.LogInformation(""); + + if (!string.IsNullOrWhiteSpace(ctx.Config.AgenticAppId)) + { + ctx.Logger.LogInformation("Agent identity already created (ID: {AgentId}). Skipping.", ctx.Config.AgenticAppId); + ctx.Results.AgentIdentityCreated = true; + ctx.Results.AgentIdentityId = ctx.Config.AgenticAppId; + ctx.Results.AgentIdentityDisplayName = ctx.Config.AgentIdentityDisplayName; + } + else + { + var agentIdentityDisplayName = ctx.Config.AgentIdentityDisplayName + ?? ctx.Config.WebAppName + ?? "Agent"; + + // Agent identity creation via delegated flow (AgentIdentity.Create.All). + // Agent ID Developer role is sufficient — client credentials are not required. + ctx.Logger.LogInformation("Creating agent identity..."); + var agentId = await ctx.GraphApiService.CreateAgentIdentityDelegatedAsync( + ctx.Config.TenantId!, + ctx.Config.AgentBlueprintId!, + agentIdentityDisplayName, + ctx.CancellationToken); + + if (agentId is not null) + { + ctx.Config.AgenticAppId = agentId; + await ctx.ConfigService.SaveStateAsync(ctx.Config); + ctx.Results.AgentIdentityCreated = true; + ctx.Results.AgentIdentityId = agentId; + ctx.Results.AgentIdentityDisplayName = agentIdentityDisplayName; + using (ctx.Logger.Indent()) + ctx.Logger.LogInformation("Agent identity created (ID: {AgentId})", agentId); + ctx.Logger.LogInformation(""); + } + else if (!ctx.Results.AgentIdentityFailed) + { + ctx.Results.AgentIdentityFailed = true; + ctx.Results.Warnings.Add("Agent identity creation failed. Ensure you have the Agent ID Developer or Agent ID Administrator role in this tenant."); + ctx.Logger.LogWarning("Agent identity creation failed. Ensure you have the Agent ID Developer or Agent ID Administrator role in this tenant."); + } + } + + // Step 5a: Grant permissions to the agent identity (non-admin path only). + // If the batch permissions step already granted AllPrincipals admin consent, skip this. + if (!string.IsNullOrWhiteSpace(ctx.Config.AgenticAppId) && !ctx.Results.AdminConsentGranted) + { + await GrantAgentIdentityPermissionsAsync(ctx, specs); + } + + // Step 6: Register agent via Graph API (copilot/agentRegistrations). + + // The agent registration represents the agent itself, not the Entra identity. + // Strip " Identity" suffix so the registry entry reads " Agent", not " Identity". + var agentDisplayName = ctx.Config.AgentIdentityDisplayName + ?? ctx.Config.WebAppName + ?? "Agent"; + if (agentDisplayName.EndsWith(" Identity", StringComparison.OrdinalIgnoreCase)) + agentDisplayName = agentDisplayName[..^" Identity".Length].TrimEnd() + " Agent"; + + ctx.Logger.LogInformation(""); + if (!string.IsNullOrWhiteSpace(ctx.Config.AgentRegistrationId)) + { + ctx.Logger.LogInformation("Registering agent..."); + using (ctx.Logger.Indent()) + ctx.Logger.LogInformation("Agent already registered (ID: {RegistrationId}). Skipping.", ctx.Config.AgentRegistrationId); + ctx.Logger.LogInformation(""); + ctx.Results.AgentInstanceRegistered = true; + ctx.Results.AgentInstanceId = ctx.Config.AgentRegistrationId; + ctx.Results.AgentRegistrationDisplayName = agentDisplayName; + } + else + { + ctx.Logger.LogInformation("Registering agent..."); + + var registrationId = await ctx.GraphApiService.RegisterAgentInstanceAsyncV2( + ctx.Config.TenantId!, + agentDisplayName, + ctx.Config.AgentDescription, + ctx.Config.AgentBlueprintId, + ctx.Config.AgenticAppId, + ctx.Config.ClientAppId, + ctx.CancellationToken); + + if (registrationId is not null) + { + ctx.Config.AgentRegistrationId = registrationId; + await ctx.ConfigService.SaveStateAsync(ctx.Config); + ctx.Results.AgentInstanceRegistered = true; + ctx.Results.AgentInstanceId = registrationId; + ctx.Results.AgentRegistrationDisplayName = agentDisplayName; + using (ctx.Logger.Indent()) + ctx.Logger.LogInformation("Agent registered (ID: {RegistrationId})", registrationId); + ctx.Logger.LogInformation(""); + } + else + { + ctx.Results.AgentRegistrationFailed = true; + ctx.Results.Warnings.Add("Agent registration failed via Graph copilot/agentRegistrations API."); + ctx.Logger.LogWarning("Agent registration failed via Graph copilot/agentRegistrations API."); + } + } + + // Sync all settings (ServiceConnection, TokenValidation, Agent365Observability) to the app config file. + ctx.Logger.LogInformation("Updating project settings..."); + using (ctx.Logger.Indent()) + { + // Pass ctx.Config directly so AgentDescription and AgentIdentityDisplayName + // derived from --agent-name are written rather than stale values from disk. + await ProjectSettingsSyncHelper.ExecuteAsync( + ctx.ConfigFile.FullName, ctx.Config, + ctx.PlatformDetector, ctx.Logger); + ctx.Results.ProjectSettingsWritten = true; + } + } + + /// + /// Grants the same oauth2 permission grants to the Agent Identity SP that the blueprint has. + /// Called after agent identity creation so the identity can acquire app-only tokens for all + /// blueprint resources (e.g. Power Platform, Observability API) via the FMI token chain. + /// This step is idempotent — safe to re-run on subsequent setup invocations. + /// + internal static async Task GrantAgentIdentityPermissionsAsync( + SetupContext ctx, + List specs) + { + if (specs.Count == 0) + { + ctx.Logger.LogDebug("No permission specs to grant to agent identity; skipping."); + return; + } + + ctx.Logger.LogDebug("Granting permissions to agent identity ({AgentId})...", ctx.Config.AgenticAppId); + + // Resolve the current developer's object ID so we can create Principal-scoped grants + // that don't require GA or Cloud App Admin. + var currentUserObjectId = await ctx.GraphApiService.GetCurrentUserObjectIdAsync( + ctx.Config.TenantId!, ctx.CancellationToken); + + if (string.IsNullOrWhiteSpace(currentUserObjectId)) + { + ctx.Logger.LogWarning( + "Could not resolve current user object ID. " + + "Permissions to the agent identity must be granted manually in the Entra portal."); + ctx.Results.Warnings.Add( + "Could not resolve current user object ID for Principal-scoped permission grants. " + + "Grant them manually in the Entra portal."); + return; + } + + var agentIdentitySpObjectId = await ctx.GraphApiService.EnsureServicePrincipalForAppIdAsync( + ctx.Config.TenantId!, + ctx.Config.AgenticAppId!, + ctx.CancellationToken, + Constants.AuthenticationConstants.RequiredPermissionGrantScopes); + + if (string.IsNullOrWhiteSpace(agentIdentitySpObjectId)) + { + ctx.Logger.LogWarning( + "Could not resolve service principal for agent identity ({AgentId}). " + + "Permissions must be granted manually in the Entra portal.", + ctx.Config.AgenticAppId); + return; + } + + var anyFailed = false; + foreach (var spec in specs) + { + if (spec.Scopes.Length == 0) continue; + + var resourceSpObjectId = await ctx.GraphApiService.EnsureServicePrincipalForAppIdAsync( + ctx.Config.TenantId!, + spec.ResourceAppId, + ctx.CancellationToken, + Constants.AuthenticationConstants.RequiredPermissionGrantScopes); + + if (string.IsNullOrWhiteSpace(resourceSpObjectId)) + { + ctx.Logger.LogWarning( + "Could not resolve SP for resource {ResourceName} ({ResourceAppId}); skipping.", + spec.ResourceName, spec.ResourceAppId); + anyFailed = true; + continue; + } + + // Query the resource SP's published scopes and filter out any that haven't + // been rolled out to this tenant yet. Attempting to grant a non-existent scope + // returns Request_BadRequest from Graph, which would surface as a misleading warning. + var availableScopes = await ctx.GraphApiService.GetAvailableScopeNamesAsync( + ctx.Config.TenantId!, resourceSpObjectId, ctx.CancellationToken); + + var scopesToGrant = availableScopes.Count > 0 + ? spec.Scopes.Where(s => availableScopes.Contains(s)).ToArray() + : spec.Scopes; // if the query failed, try all and let Graph surface any real error + + if (scopesToGrant.Length == 0) + { + ctx.Logger.LogInformation( + "Scopes [{Scopes}] not yet available on {ResourceName} in this tenant — skipping.", + string.Join(" ", spec.Scopes), spec.ResourceName); + continue; + } + + var granted = await ctx.GraphApiService.CreatePrincipalOauth2PermissionGrantAsync( + ctx.Config.TenantId!, + agentIdentitySpObjectId, + resourceSpObjectId, + currentUserObjectId, + scopesToGrant, + ctx.CancellationToken, + Constants.AuthenticationConstants.RequiredPermissionGrantScopes); + + if (granted) + ctx.Logger.LogDebug( + "Granted {Scopes} on {ResourceName} to agent identity (principal scope).", + string.Join(" ", scopesToGrant), spec.ResourceName); + else + { + ctx.Logger.LogWarning( + "Failed to grant {Scopes} on {ResourceName} to agent identity.", + string.Join(" ", scopesToGrant), spec.ResourceName); + anyFailed = true; + } + } + + if (anyFailed) + ctx.Results.Warnings.Add( + "One or more permissions could not be granted to the agent identity. " + + "Check the log output and grant them manually in the Entra portal."); + else + { + var grantedNames = string.Join(", ", specs.Where(s => s.Scopes.Length > 0).Select(s => s.ResourceName)); + using (ctx.Logger.Indent()) + ctx.Logger.LogInformation("Developer-scoped permissions granted ({Resources}).", grantedNames); + ctx.Results.AgentIdentityPermissionsGranted = true; + } + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwSetupOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwSetupOrchestrator.cs new file mode 100644 index 00000000..9a5902a4 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwSetupOrchestrator.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// Orchestrates setup for non-AI Teammate agent (non-digital-worker) deployments. +/// Uses standard App Registration + Azure Bot Service pattern — no Agent Identity Blueprint. +/// +/// Phase A (current): dry-run plan output only. +/// Phase B (pending team feedback): full Azure resource provisioning. +/// +internal static class NonDwSetupOrchestrator +{ + // Teams client app IDs required for SSO pre-authorization (Expose an API) + internal const string TeamsDesktopMobileClientId = "1fec8e78-bce4-4aaf-ab1b-5451cc387264"; + internal const string TeamsWebClientId = "5e3ce6c0-2b1f-4285-8d4b-75ee78787346"; + + // OAuth connection name created on the Azure Bot for OBO token exchange + internal const string OboConnectionName = "GraphOBoConnection"; + + // Microsoft Graph delegated permissions added to the app registration + internal static readonly string[] GraphDelegatedPermissions = + [ + "User.Read", "openid", "profile", "email", "offline_access" + ]; + + // Agent 365 Tools delegated permissions added to the app registration + internal static readonly string[] Agent365ToolsDelegatedPermissions = + [ + "McpServers.Mail.All", "McpServersMetadata.Read.All", "AgentTools.ListMCPServers.All" + ]; + + /// + /// Prints a dry-run plan showing all resources that would be created or configured, + /// using actual names and values from the loaded config. Makes no API calls. + /// + public static void PrintDryRunPlan(Agent365Config config, ILogger logger) + { + var displayName = config.AgentIdentityDisplayName; + var rg = config.ResourceGroup; + + var messagingEndpoint = !string.IsNullOrWhiteSpace(config.MessagingEndpoint) + ? config.MessagingEndpoint + : config.NeedDeployment && !string.IsNullOrWhiteSpace(config.WebAppName) + ? $"https://{config.WebAppName}.azurewebsites.net/api/messages" + : ""; + + logger.LogWarning( + "Non-AI Teammate setup (classic App Registration path) is not yet fully implemented. " + + "Use --use-blueprint for the blueprint-based non-DW setup path."); + logger.LogInformation(""); + logger.LogInformation("Non-DW Setup Plan (dry run — no changes will be made)"); + logger.LogInformation(""); + + // App Registration + logger.LogInformation(" App Registration"); + logger.LogInformation(" Create App Registration: \"{DisplayName}\" (multi-tenant)", displayName); + logger.LogInformation(" Create Client Secret: expires in 2 years"); + logger.LogInformation(" Configure API Identifier URI: api://botid-"); + logger.LogInformation(" Create Scope: access_as_user"); + logger.LogInformation(" Configure Pre-authorization: Teams desktop ({TeamsDesktop})", TeamsDesktopMobileClientId); + logger.LogInformation(" Teams web ({TeamsWeb})", TeamsWebClientId); + logger.LogInformation(" Assign API Permissions: Microsoft Graph: {GraphScopes}", + string.Join(", ", GraphDelegatedPermissions)); + logger.LogInformation(" Agent 365 Tools: {A365Scopes}", + string.Join(", ", Agent365ToolsDelegatedPermissions)); + logger.LogInformation(""); + + // Azure Resources + logger.LogInformation(" Azure Resources"); + + if (config.NeedDeployment && !string.IsNullOrWhiteSpace(config.WebAppName)) + { + var acrName = DeriveAcrName(config.WebAppName); + logger.LogInformation(" Create Container Registry: {AcrName} sku: Basic", acrName); + logger.LogInformation(" Create App Service Plan: {PlanName} sku: {Sku} Linux", + config.AppServicePlanName, string.IsNullOrWhiteSpace(config.AppServicePlanSku) + ? ConfigConstants.DefaultAppServicePlanSku + : config.AppServicePlanSku); + logger.LogInformation(" Create Web App: {WebAppName} Docker Linux", config.WebAppName); + } + else + { + logger.LogInformation(" Skip Deployment infrastructure: needDeployment is false"); + } + + if (config.NeedAzureOpenAI) + { + var aoaiName = config.AzureOpenAIName ?? $"{displayName}-aoai"; + var aoaiLocation = config.AzureOpenAILocation ?? config.Location; + logger.LogInformation(" Create Azure OpenAI: {AoaiName} location: {Location}", + aoaiName, aoaiLocation); + if (!string.IsNullOrWhiteSpace(config.AzureOpenAIModelDeploymentName)) + logger.LogInformation(" Deploy Model: {ModelName}", config.AzureOpenAIModelDeploymentName); + } + + logger.LogInformation(""); + + // Register Messaging Endpoint + logger.LogInformation(" Register Messaging Endpoint"); + logger.LogInformation(" Create Azure Bot: \"{DisplayName}\" rg: {ResourceGroup} sku: F0", + displayName, rg); + logger.LogInformation(" Configure Messaging Endpoint: {Endpoint}", messagingEndpoint); + logger.LogInformation(" Create Teams Channel"); + logger.LogInformation(" Create OAuth Connection: {ConnectionName}", OboConnectionName); + logger.LogInformation(" scopes: api://botid-/access_as_user"); + logger.LogInformation(" tokenExchangeUrl: api://botid-"); + logger.LogInformation(""); + + logger.LogInformation("Run without --dry-run to execute these steps."); + } + + /// + /// Derives an ACR-compatible name from a web app name. + /// ACR names must be alphanumeric, 5-50 chars, globally unique. + /// + private static string DeriveAcrName(string webAppName) + { + var candidate = new string(webAppName + .ToLowerInvariant() + .Where(char.IsLetterOrDigit) + .ToArray()); + + // ACR names must start with a letter; prefix 'a' if the first char is a digit. + if (candidate.Length > 0 && !char.IsLetter(candidate[0])) + candidate = "a" + candidate; + + if (candidate.Length < 5) + candidate = candidate.PadRight(5, '0'); + + return candidate.Length > 50 ? candidate[..50] : candidate; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs index 8d68c668..84f64dd3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs @@ -21,7 +21,8 @@ public static Command CreateCommand( ILogger logger, IConfigService configService, AzureAuthValidator authValidator, - IClientAppValidator clientAppValidator) + IClientAppValidator clientAppValidator, + IEnumerable? requirementChecksOverride = null) { var command = new Command("requirements", "Validate prerequisites for Agent 365 setup\n" + @@ -59,7 +60,7 @@ public static Command CreateCommand( { // Load configuration var setupConfig = await configService.LoadAsync(config.FullName); - var requirementChecks = GetRequirementChecks(authValidator, clientAppValidator); + var requirementChecks = requirementChecksOverride?.ToList() ?? GetRequirementChecks(authValidator, clientAppValidator); await RunRequirementChecksAsync(requirementChecks, setupConfig, logger, category); } catch (Exception ex) @@ -151,7 +152,6 @@ public static async Task RunChecksOrExitAsync( var passed = await RunRequirementChecksAsync(checks, config, logger, category: null, cancellationToken); if (!passed) { - logger.LogError("Operation cannot proceed due to failed requirement checks above. Please fix the issues and retry."); ExceptionHandler.ExitWithCleanup(1); } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupContext.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupContext.cs new file mode 100644 index 00000000..1a815c3f --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupContext.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// Bundles the mutable step state and shared services for setup orchestration. +/// Passed to each extracted step method so state flows cleanly between steps +/// without scattered local variables. +/// +/// is intentionally mutable — the blueprint step reloads +/// configuration from disk after writing AgentBlueprintId, and the updated +/// instance must be visible to subsequent steps. +/// +internal sealed class SetupContext +{ + /// Mutable config — reloaded by blueprint step after it writes to disk. + public Agent365Config Config { get; set; } + + /// Per-step result tracking for summary display. + public SetupResults Results { get; } + + public ILogger Logger { get; } + + /// The static config file (a365.config.json). + public FileInfo ConfigFile { get; } + + /// Full path to a365.generated.config.json. + public string GeneratedConfigPath { get; } + + /// Correlation ID generated at workflow entry for distributed tracing. + public string CorrelationId { get; } + + /// When true, Step 1 (infrastructure) is skipped. Always true for non-DW blueprint. + public bool SkipInfrastructure { get; } + + /// When true, requirements validation is skipped. + public bool SkipRequirements { get; } + + /// When true, only the agent instance registration step is run (non-DW blueprint only). + public bool AgentInstanceOnly { get; } + + /// + /// When true, config was built from --agent-name (no config file). Infrastructure step is + /// always skipped, ValidateNonDwMinimal() is used instead of Validate(), and config is not + /// persisted back to disk. + /// + public bool IsBootstrap { get; } + + /// + /// Overrides the az CLI login hint resolver used during blueprint creation. + /// Null in production — injected as a no-op in tests to avoid spawning 'az account show'. + /// + public Func>? LoginHintResolver { get; } + + /// + /// Handles interactive yes/no prompts. Defaults to ; + /// inject a in tests to avoid console I/O. + /// + public IConfirmationProvider ConfirmationProvider { get; } + + public CancellationToken CancellationToken { get; } + + // Services + public IConfigService ConfigService { get; } + public CommandExecutor Executor { get; } + public IBotConfigurator BotConfigurator { get; } + public AzureAuthValidator AuthValidator { get; } + public PlatformDetector PlatformDetector { get; } + public GraphApiService GraphApiService { get; } + public AgentBlueprintService BlueprintService { get; } + public BlueprintLookupService BlueprintLookupService { get; } + public FederatedCredentialService FederatedCredentialService { get; } + public IClientAppValidator ClientAppValidator { get; } + + public SetupContext( + Agent365Config config, + SetupResults results, + ILogger logger, + FileInfo configFile, + string generatedConfigPath, + string correlationId, + bool skipInfrastructure, + bool skipRequirements, + CancellationToken cancellationToken, + IConfigService configService, + CommandExecutor executor, + IBotConfigurator botConfigurator, + AzureAuthValidator authValidator, + PlatformDetector platformDetector, + GraphApiService graphApiService, + AgentBlueprintService blueprintService, + BlueprintLookupService blueprintLookupService, + FederatedCredentialService federatedCredentialService, + IClientAppValidator clientAppValidator, + bool agentInstanceOnly = false, + bool isBootstrap = false, + Func>? loginHintResolver = null, + IConfirmationProvider? confirmationProvider = null) + { + Config = config; + Results = results; + Logger = logger; + ConfigFile = configFile; + GeneratedConfigPath = generatedConfigPath; + CorrelationId = correlationId; + SkipInfrastructure = skipInfrastructure; + SkipRequirements = skipRequirements; + CancellationToken = cancellationToken; + AgentInstanceOnly = agentInstanceOnly; + IsBootstrap = isBootstrap; + ConfigService = configService; + Executor = executor; + BotConfigurator = botConfigurator; + AuthValidator = authValidator; + PlatformDetector = platformDetector; + GraphApiService = graphApiService; + BlueprintService = blueprintService; + BlueprintLookupService = blueprintLookupService; + FederatedCredentialService = federatedCredentialService; + ClientAppValidator = clientAppValidator; + LoginHintResolver = loginHintResolver; + ConfirmationProvider = confirmationProvider ?? new ConsoleConfirmationProvider(); + } +} 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 fc69182a..32ac45ad 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -3,6 +3,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; @@ -16,13 +17,33 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; /// internal static class SetupHelpers { + // ── Dry-run layout helpers ───────────────────────────────────────────────── + // Shared by PrintDwSetupAllDryRunPlan (DW path) and NonDwBlueprintSetupOrchestrator.PrintDryRunPlan + // (non-DW path) so the column width and blueprint-reuse wording stay in sync. + internal const int DryRunValCol = 30; internal static string DryRunRow(string label) => (" " + label).PadRight(DryRunValCol); internal static string DryRunRow(int step, string label) => $" {step}. {label}".PadRight(DryRunValCol); + /// + /// Prints the six blueprint-reuse rows common to both DW and non-DW dry-run plans. + /// Called when AgentBlueprintId is already present in config. + /// + internal static void PrintDryRunBlueprintReuseRows(ILogger logger, string blueprintId, int step = 3) + { + var sub = new string(' ', DryRunValCol); + logger.LogInformation(DryRunRow(step, "Blueprint") + "reuse (ID: {BlueprintId})", blueprintId); + logger.LogInformation(sub + "verify or create service principal"); + logger.LogInformation(sub + "create client secret"); + logger.LogInformation(sub + "verify or create federated identity credential (FIC)"); + logger.LogInformation(sub + "verify or create managed identity"); + } + + // ────────────────────────────────────────────────────────────────────────── + /// /// Returns the fixed-scope ResourcePermissionSpecs for the three platform APIs that every - /// agent blueprint requires: Messaging Bot API, Observability API, and Power Platform API. + /// DW (AI Teammate) agent blueprint requires: Messaging Bot API, Observability API, and Power Platform API. /// Callers control whether the specs set inheritable permissions on the blueprint. /// internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs(bool setInheritable) => @@ -45,6 +66,224 @@ internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs(bool setInhe setInheritable), ]; + /// + /// Returns the fixed-scope ResourcePermissionSpecs for the non-DW (blueprint) path: + /// Observability API and Power Platform API only. + /// Messaging Bot API is DW-only. Microsoft Graph and Agent 365 Tools (MCP) are not + /// included — they are added by the DW flow via BuildPermissionSpecsAsync. + /// To enable MCP or Messaging Bot API for non-DW, add their specs here and update + /// the corresponding consent URL guards in BuildAdminConsentUrls / BuildCombinedConsentUrl. + /// + internal static ResourcePermissionSpec[] GetNonDwFixedApiPermissionSpecs(bool setInheritable) => + [ + new ResourcePermissionSpec( + ConfigConstants.ObservabilityApiAppId, + "Observability API", + new[] { ConfigConstants.ObservabilityApiOtelWriteScope }, + setInheritable), + new ResourcePermissionSpec( + PowerPlatformConstants.PowerPlatformApiResourceAppId, + "Power Platform API", + new[] { PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead }, + setInheritable), + ]; + + /// + /// Builds the full resource permission spec list from config for the DW/config-dir flows. + /// Includes Microsoft Graph, manifest-derived Agent 365 Tools scopes, fixed platform APIs, + /// and any custom blueprint permissions. + /// + /// Pass a pre-computed to avoid reading the MCP manifest + /// a second time when the caller already has it (e.g. AllSubcommand.BuildPermissionSpecsAsync). + /// When null, the manifest is read from config.DeploymentProjectPath. + /// + /// + internal static async Task> BuildConfiguredPermissionSpecsAsync( + Agent365Config config, + bool setInheritable, + Dictionary? scopesByAudience = null) + { + if (scopesByAudience is null) + { + var mcpManifestPath = Path.Combine( + config.DeploymentProjectPath ?? string.Empty, + McpConstants.ToolingManifestFileName); + scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(mcpManifestPath, excludeLegacyAtg: false); + } + + var specs = new List + { + new( + AuthenticationConstants.MicrosoftGraphResourceAppId, + "Microsoft Graph", + config.AgentApplicationScopes.ToArray(), + SetInheritable: setInheritable), + }; + + specs.AddRange(scopesByAudience.Select(kvp => + new ResourcePermissionSpec(kvp.Key, "Agent 365 Tools", kvp.Value, SetInheritable: setInheritable))); + specs.AddRange(GetFixedApiPermissionSpecs(setInheritable)); + + foreach (var customPerm in config.CustomBlueprintPermissions ?? new List()) + { + var (isValid, _) = customPerm.Validate(); + if (isValid && !string.IsNullOrWhiteSpace(customPerm.ResourceAppId)) + { + var resourceName = string.IsNullOrWhiteSpace(customPerm.ResourceName) + ? customPerm.ResourceAppId + : customPerm.ResourceName; + specs.Add(new ResourcePermissionSpec( + customPerm.ResourceAppId, + resourceName, + customPerm.Scopes.ToArray(), + SetInheritable: setInheritable)); + } + } + + return specs; + } + + /// + /// Resolves the tenant ID for config-free bootstrap flows. + /// Uses the explicit flag first, then falls back to the current Azure CLI context. + /// + internal static Task ResolveBootstrapTenantIdAsync( + string? tenantIdFlag, + CommandExecutor executor, + ILogger logger) => + string.IsNullOrWhiteSpace(tenantIdFlag) + ? TenantDetectionHelper.DetectTenantIdAsync(null, logger, executor) + : Task.FromResult(tenantIdFlag); + + /// + /// Resolves the client app ID for config-free bootstrap flows. + /// Optionally prefers a matching local a365.config.json value before falling back to + /// the well-known Entra display name lookup. + /// + internal static async Task ResolveBootstrapClientAppIdAsync( + string tenantId, + GraphApiService? graphApiService, + ILogger logger, + CancellationToken ct, + bool preferLocalConfig = false) + { + string? clientAppId = null; + + if (preferLocalConfig) + { + clientAppId = await TryGetLocalClientAppIdAsync(tenantId, logger, ct); + if (!string.IsNullOrWhiteSpace(clientAppId)) + logger.LogDebug("Using client app ID from local a365.config.json (tenant matches)."); + } + + if (string.IsNullOrWhiteSpace(clientAppId) && graphApiService != null) + { + logger.LogInformation("Resolving client app by display name \"{Name}\"...", + AuthenticationConstants.WellKnownClientAppDisplayName); + clientAppId = await graphApiService.FindApplicationByDisplayNameAsync( + tenantId, + AuthenticationConstants.WellKnownClientAppDisplayName, + ct); + } + + return clientAppId; + } + + private static async Task TryGetLocalClientAppIdAsync( + string tenantId, + ILogger logger, + CancellationToken ct) + { + var localStaticConfigPath = Path.Combine(Environment.CurrentDirectory, ConfigConstants.DefaultConfigFileName); + if (!File.Exists(localStaticConfigPath)) + return null; + + try + { + var staticJson = await File.ReadAllTextAsync(localStaticConfigPath, ct); + using var staticDoc = JsonDocument.Parse(staticJson); + var staticRoot = staticDoc.RootElement; + var configTenantId = GetJsonString(staticRoot, "tenantId"); + var configClientAppId = GetJsonString(staticRoot, "clientAppId"); + return string.Equals(configTenantId, tenantId, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(configClientAppId) + ? configClientAppId + : null; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Could not parse {Path} for clientAppId.", localStaticConfigPath); + return null; + } + } + + internal static string? GetJsonString(JsonElement element, string key) => + element.TryGetProperty(key, out var val) && val.ValueKind == JsonValueKind.String + ? val.GetString() + : null; + + /// + /// Fixed permission specs for the non-DW admin consent flow. + /// Observability API and Power Platform API — both delegated. + /// Extend this list or pass an override to + /// when additional APIs are required (e.g. dynamic MCP scopes, custom permissions). + /// + internal static readonly IReadOnlyList<(string ResourceName, string ResourceAppId, string Scope, string PermissionType)> NonDwAdminConsentSpecs = + [ + ("Observability API", ConfigConstants.ObservabilityApiAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Delegated"), + ("Power Platform API", PowerPlatformConstants.PowerPlatformApiResourceAppId, PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead, "Delegated"), + ]; + + /// + /// Logs step-by-step instructions for a Global Administrator to grant admin consent + /// for the blueprint app, with two options: Entra portal and PowerShell. + /// + /// Defaults to (Observability API + Power Platform API). + /// Pass an explicit list to support dynamic or extended permission sets. + /// + /// + internal static void LogNonDwAdminConsentInstructions( + ILogger logger, + string blueprintId, + IReadOnlyList<(string ResourceName, string ResourceAppId, string Scope, string PermissionType)>? specs = null) + { + specs ??= NonDwAdminConsentSpecs; + + // Option A — Entra portal + logger.LogInformation(" Option A — Entra portal:"); + logger.LogInformation(" 1. Open https://entra.microsoft.com"); + logger.LogInformation(" 2. Navigate to: Identity > Applications > App registrations"); + logger.LogInformation(" 3. Search for the blueprint app by ID: {BlueprintId}", blueprintId); + logger.LogInformation(" (switch to 'All applications' tab if not shown under 'Owned applications')"); + logger.LogInformation(" 4. Open the app, go to: API permissions"); + logger.LogInformation(" 5. Confirm the following permissions are listed:"); + foreach (var (resourceName, _, scope, permType) in specs) + logger.LogInformation(" - {ResourceName,-20}: {Scope} ({PermType})", resourceName, scope, permType); + logger.LogInformation(" 6. Click 'Grant admin consent for your organization' and confirm"); + + // Option B — PowerShell (Microsoft Graph SDK) + logger.LogInformation(""); + logger.LogInformation(" Option B — PowerShell (Microsoft.Graph module required):"); + logger.LogInformation(" Install-Module Microsoft.Graph -Scope CurrentUser # skip if already installed"); + logger.LogInformation(" Connect-MgGraph -Scopes \"DelegatedPermissionGrant.ReadWrite.All\""); + logger.LogInformation(" $sp = (Get-MgServicePrincipal -Filter \"appId eq '{BlueprintId}'\").Id", blueprintId); + foreach (var (resourceName, resourceAppId, _, _) in specs) + { + var varName = "$" + resourceName.Replace(" ", "").Replace("API", "").ToLowerInvariant(); + logger.LogInformation(" {Var} = (Get-MgServicePrincipal -Filter \"appId eq '{AppId}'\").Id # {Name}", + varName, resourceAppId, resourceName); + } + foreach (var (resourceName, _, scope, _) in specs) + { + var varName = "$" + resourceName.Replace(" ", "").Replace("API", "").ToLowerInvariant(); + // Use Invoke-MgGraphRequest instead of New-MgOauth2PermissionGrant to avoid + // assembly conflicts when Microsoft.Graph.Identity.SignIns is already partially loaded. + logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' \\"); + logger.LogInformation(" -Body @{{ clientId = $sp; consentType = 'AllPrincipals'; resourceId = {Var}; scope = '{Scope}' }}", + varName, scope); + } + } + /// /// Display verification URLs after successful setup /// @@ -106,81 +345,137 @@ public static async Task DisplayVerificationInfoAsync(FileInfo setupConfigFile, /// /// Display comprehensive setup summary showing what succeeded and what failed /// - public static void DisplaySetupSummary(SetupResults results, ILogger logger) + public static void DisplaySetupSummary(SetupResults results, ILogger logger, bool isDw = true) { + // Prefer the flag set on results — it is reliable regardless of which code path calls this. + var isNonDw = results.IsNonDwBlueprintFlow || !isDw; + var notRun = "not run (previous step failed)"; + logger.LogInformation(""); logger.LogInformation("Setup Summary"); logger.LogInformation(""); var pendingAdminAction = !results.AdminConsentGranted && results.BatchPermissionsPhase2Completed; + var pendingS2SAction = results.S2SAppRoleGranted == false; - // Numbered step rows — az CLI style: label padded to fixed column, then status word - var step = 0; + // ── Numbered step rows — mirrors the dry-run step list ───────────────── + // Non-DW omits the Azure hosting step, so all steps after 1 are shifted down by 1. + var s = isNonDw ? 0 : 1; // step offset: non-DW steps start at 2 (blueprint), DW at 3 - if (results.InfrastructureCreated) + // 1. Prerequisites + logger.LogInformation(DryRunRow(1, "Prerequisites") + (results.PrerequisitesSkipped ? "skipped" : "validated")); + + // 2. Azure hosting (DW only — not applicable for non-DW blueprint flow) + if (!isNonDw) { - step++; - logger.LogInformation(DryRunRow(step, "Azure hosting") + (results.InfrastructureAlreadyExisted ? "reused" : "provisioned")); + if (results.InfrastructureSkipped) + logger.LogInformation(DryRunRow(2, "Azure hosting") + "skipped"); + else if (results.InfrastructureCreated) + logger.LogInformation(DryRunRow(2, "Azure hosting") + (results.InfrastructureAlreadyExisted ? "reused" : "provisioned")); + else + logger.LogError(DryRunRow(2, "Azure hosting") + "failed"); } + // Blueprint: step 2 (non-DW) or step 3 (DW) if (results.BlueprintCreated) { - step++; var bpStatus = results.BlueprintAlreadyExisted ? "reused" : "created"; - logger.LogInformation(DryRunRow(step, "Blueprint") + "{Status} ID: {Id}", bpStatus, results.BlueprintId ?? "unknown"); + if (!results.BlueprintServicePrincipalCreated) + logger.LogWarning(DryRunRow(2 + s, "Blueprint") + "{Status} (service principal failed — see warnings) '{Name}' (ID: {Id})", + bpStatus, results.BlueprintDisplayName ?? "unknown", results.BlueprintId ?? "unknown"); + else + logger.LogInformation(DryRunRow(2 + s, "Blueprint") + "{Status} '{Name}' (ID: {Id})", + bpStatus, results.BlueprintDisplayName ?? "unknown", results.BlueprintId ?? "unknown"); } - - if (results.BatchPermissionsPhase2Completed) + else if (results.BlueprintFailed) + logger.LogError(DryRunRow(2 + s, "Blueprint") + "failed"); + + // Inheritable Permissions: step 3 (non-DW) or step 4 (DW) + if (results.BlueprintFailed) + logger.LogInformation(DryRunRow(3 + s, "Inheritable Permissions") + notRun); + else if (results.BatchPermissionsPhase1Completed) + logger.LogInformation(DryRunRow(3 + s, "Inheritable Permissions") + "configured"); + + // Permission Grants: step 4 (non-DW) or step 5 (DW) + if (results.BlueprintFailed) + logger.LogInformation(DryRunRow(4 + s, "Permission Grants") + notRun); + else if (results.AgentIdentityPermissionsGranted) + logger.LogInformation(DryRunRow(4 + s, "Permission Grants") + "ok (developer-scoped)"); + else if (results.BatchPermissionsPhase2Completed) + logger.LogInformation(DryRunRow(4 + s, "Permission Grants") + (results.AdminConsentGranted ? "ok" : "PENDING")); + + // Non-DW only: Agent identity (5) and Agent Registration (6) + if (isNonDw) { - step++; - var grantStatus = results.AdminConsentGranted ? "ok" : "PENDING"; - logger.LogInformation(DryRunRow(step, "Permissions") + grantStatus); + if (results.BlueprintFailed) + { + logger.LogInformation(DryRunRow(5, "Agent identity") + notRun); + logger.LogInformation(DryRunRow(6, "Agent Registration") + notRun); + } + else + { + if (results.AgentIdentityCreated) + logger.LogInformation(DryRunRow(5, "Agent identity") + "created '{Name}' (ID: {Id})", + results.AgentIdentityDisplayName ?? "unknown", results.AgentIdentityId ?? "unknown"); + else if (results.AgentIdentityFailed) + logger.LogWarning(DryRunRow(5, "Agent identity") + "failed — see warnings"); + + if (results.AgentInstanceRegistered) + logger.LogInformation(DryRunRow(6, "Agent Registration") + "registered '{Name}' (ID: {Id})", + results.AgentRegistrationDisplayName ?? "unknown", results.AgentInstanceId ?? "unknown"); + else if (results.AgentRegistrationFailed) + logger.LogWarning(DryRunRow(6, "Agent Registration") + "failed — see warnings"); + } } - if (results.MessagingEndpointRegistered) - { - step++; - logger.LogInformation(DryRunRow(step, "Messaging endpoint") + (results.EndpointAlreadyExisted ? "reused" : "registered")); - } + // Project settings: step 7 for non-DW, step 6 for DW + var settingsStep = isNonDw ? 7 : 6; + if (results.BlueprintFailed) + logger.LogInformation(DryRunRow(settingsStep, "Project settings") + notRun); + else if (results.ProjectSettingsWritten) + logger.LogInformation(DryRunRow(settingsStep, "Project settings") + "written"); - // Action required — one numbered section with inline instructions - var pendingS2SAction = results.S2SAppRoleGranted == false; + // ── Action Required ──────────────────────────────────────────────────── var hasActionRequired = pendingAdminAction || results.ClientSecretManualActionRequired || pendingS2SAction; if (hasActionRequired) { var blueprintAppId = results.BlueprintId ?? ""; var consentUrl = results.CombinedConsentUrl ?? results.AdminConsentUrl; - var itemNum = 0; logger.LogInformation(""); logger.LogInformation("Action Required:"); - + int actionCount = 0; if (results.ClientSecretManualActionRequired) { - itemNum++; - logger.LogInformation(" {N}. Client secret — create manually in Entra ID and add to a365.generated.config.json (see instructions above)", itemNum); + actionCount++; + logger.LogInformation(" {N}. Client secret — create manually in the Entra portal for app {AppId}.", actionCount, results.BlueprintId ?? ""); + logger.LogInformation(" Add it to a365.generated.config.json as 'agentBlueprintClientSecret', then re-run setup."); + logger.LogInformation(" See: https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials"); } - - if (pendingAdminAction && !string.IsNullOrWhiteSpace(consentUrl)) + if (pendingAdminAction) { - itemNum++; - logger.LogInformation(" {N}. OAuth2 permission grants — share this URL with your Global Administrator:", itemNum); - logger.LogInformation(" {ConsentUrl}", consentUrl); + actionCount++; + var adminCmdBlueprintId = results.BlueprintId ?? ""; + if (isDw) + { + logger.LogInformation(" {N}. Permission Grants — a Global Administrator must run:", actionCount); + logger.LogInformation(" a365 setup admin --blueprint-id {BlueprintId}", adminCmdBlueprintId); + if (!string.IsNullOrWhiteSpace(consentUrl)) + { + logger.LogInformation(" Or share this URL with the administrator to grant consent via browser:"); + logger.LogInformation(" {ConsentUrl}", consentUrl); + } + } + else + { + logger.LogInformation(" {N}. Permission Grants — a Global Administrator must grant admin consent in the Entra portal:", actionCount); + LogNonDwAdminConsentInstructions(logger, adminCmdBlueprintId); + } } - if (pendingS2SAction) { - itemNum++; - logger.LogInformation(" {N}. Observability API permissions — Global Administrator action required:", itemNum); - logger.LogInformation(""); - logger.LogInformation(" Option A — Entra portal (covers delegated + application in one step):"); - logger.LogInformation(" 1. Entra portal > App registrations > Blueprint app > API permissions"); - logger.LogInformation(" 2. Add a permission > APIs my organization uses > search {ObsApiAppId}", ConfigConstants.ObservabilityApiAppId); - logger.LogInformation(" 3. Delegated permissions > select {ObsScope} > Add permissions", ConfigConstants.ObservabilityApiOtelWriteScope); - logger.LogInformation(" 4. Repeat step 2, Application permissions > select {ObsScope} > Add permissions", ConfigConstants.ObservabilityApiOtelWriteScope); - logger.LogInformation(" 5. Grant admin consent for "); - logger.LogInformation(""); - logger.LogInformation(" Option B — PowerShell (application permission only; also complete Option A steps 2-5 for delegated):"); + actionCount++; + logger.LogInformation(" {N}. Observability API S2S app role — run as Global Administrator (PowerShell):", actionCount); logger.LogInformation(" Connect-MgGraph -Scopes 'AppRoleAssignment.ReadWrite.All'"); logger.LogInformation(" $bp = Get-MgServicePrincipal -Filter \"appId eq '{BlueprintAppId}'\"", blueprintAppId); logger.LogInformation(" $obs = Get-MgServicePrincipal -Filter \"appId eq '{ObsApiAppId}'\"", ConfigConstants.ObservabilityApiAppId); @@ -197,73 +492,77 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger) logger.LogError(" {Error}", error); } + // ── Warnings ─────────────────────────────────────────────────────────── if (results.Warnings.Count > 0) { logger.LogInformation(""); logger.LogInformation("Warnings:"); foreach (var warning in results.Warnings) - logger.LogInformation(" {Warning}", warning); + logger.LogWarning(" {Warning}", warning); } logger.LogInformation(""); - // Overall status - + // Overall status line if (results.HasErrors) - { logger.LogWarning("Setup completed with errors"); - logger.LogInformation(""); - logger.LogInformation("Recovery Actions:"); + else if (hasActionRequired) + logger.LogWarning("Setup completed — action required before proceeding"); + else if (results.HasWarnings) + logger.LogInformation("Setup completed successfully with warnings"); + else + logger.LogInformation("Setup completed successfully"); - if (!results.BatchPermissionsPhase2Completed || (!results.AdminConsentGranted && !pendingAdminAction)) - { - logger.LogInformation(" - Permissions: Run 'a365 setup all' to retry permission configuration"); - } - } + // Next steps + var hasNextSteps = results.HasErrors + || !string.IsNullOrEmpty(results.GraphInheritablePermissionsError) + || !string.IsNullOrEmpty(results.FederatedCredentialError); - if (!results.HasErrors && !hasActionRequired) + if (hasNextSteps) { - if (results.HasWarnings) - { - logger.LogInformation("Setup completed successfully with warnings"); - logger.LogInformation(""); - logger.LogInformation("Recovery Actions:"); + var nextStepLines = new List(); - if (!string.IsNullOrEmpty(results.GraphInheritablePermissionsError)) - { - logger.LogInformation(" - Graph Inheritable Permissions: Run 'a365 setup blueprint' to retry"); - } + if ((!results.BatchPermissionsPhase2Completed || (!results.AdminConsentGranted && !pendingAdminAction)) && results.HasErrors) + nextStepLines.Add(() => logger.LogInformation(" To retry permissions: a365 setup all")); - if (!string.IsNullOrEmpty(results.FederatedCredentialError)) - { - logger.LogInformation(" - Federated Identity Credential: Ensure the client app has 'AgentIdentityBlueprint.UpdateAuthProperties.All' consented,"); - logger.LogInformation(" then run 'a365 setup blueprint' to retry"); - } + if (!string.IsNullOrEmpty(results.GraphInheritablePermissionsError)) + nextStepLines.Add(() => logger.LogInformation(" To retry Graph inheritable permissions: a365 setup blueprint")); - logger.LogInformation(""); - logger.LogInformation("Review warnings above and take action if needed"); + if (!string.IsNullOrEmpty(results.FederatedCredentialError)) + { + nextStepLines.Add(() => + { + logger.LogInformation(" Ensure 'AgentIdentityBlueprint.UpdateAuthProperties.All' is consented, then:"); + logger.LogInformation(" a365 setup blueprint"); + }); } - else + + if (nextStepLines.Count > 0) { - logger.LogInformation("Setup completed successfully"); - logger.LogInformation("All components configured correctly"); + logger.LogInformation(""); + logger.LogInformation("Next steps:"); + foreach (var line in nextStepLines) + line(); } } } /// - /// Populates resourceConsents[*].consentUrl in the generated config for all five required + /// Populates resourceConsents[*].consentUrl in the generated config for the required /// resources. Called when the current user lacks the Global Administrator role so that the URLs /// can be saved to a365.generated.config.json and shared with a tenant administrator. + /// When is false (non-DW blueprint path), only Observability API and + /// Power Platform API URLs are generated — Graph, MCP, and Messaging Bot API are excluded. /// /// Display names of the resources for which URLs were saved. internal static List PopulateAdminConsentUrls( Agent365Config config, string mcpResourceAppId, - IEnumerable mcpScopes) + IEnumerable mcpScopes, + bool isDw = true) { - var graphScopes = config.AgentApplicationScopes; - var urls = BuildAdminConsentUrls(config.TenantId, config.AgentBlueprintId!, graphScopes, mcpScopes); + var graphScopes = isDw ? config.AgentApplicationScopes : Enumerable.Empty(); + var urls = BuildAdminConsentUrls(config.TenantId, config.AgentBlueprintId!, graphScopes, mcpScopes, isDw); // Map resource names to App IDs for upsert into ResourceConsents var appIdByName = new Dictionary(StringComparer.OrdinalIgnoreCase) @@ -315,58 +614,99 @@ internal static string BuildAdminConsentUrl(string tenantId, string clientId, IE } /// - /// Builds per-resource admin consent URLs for all five required resources. - /// Graph and MCP scopes are taken from config; Bot API, Observability, and Power Platform - /// use corrected scope names derived from querying the tenant service principals. + /// Builds per-resource admin consent URLs. DW path produces five resources (Graph, MCP, + /// Messaging Bot API, Observability API, Power Platform API). Non-DW path produces two + /// (Observability API and Power Platform API only) — controlled by . /// internal static List<(string ResourceName, string ConsentUrl)> BuildAdminConsentUrls( string tenantId, string blueprintClientId, IEnumerable graphScopes, - IEnumerable mcpScopes) + IEnumerable mcpScopes, + bool isDw = true) { var urls = new List<(string, string)>(); static string Build(string tenant, string client, string resourceUri, IEnumerable scopes) => BuildAdminConsentUrl(tenant, client, scopes.Select(s => $"{resourceUri}/{s}")); - var graphScopeList = graphScopes.ToList(); - if (graphScopeList.Count > 0) - urls.Add(("Microsoft Graph", Build(tenantId, blueprintClientId, AuthenticationConstants.MicrosoftGraphResourceUri, graphScopeList))); + if (isDw) + { + var graphScopeList = graphScopes.ToList(); + if (graphScopeList.Count > 0) + urls.Add(("Microsoft Graph", Build(tenantId, blueprintClientId, AuthenticationConstants.MicrosoftGraphResourceUri, graphScopeList))); + + var mcpScopeList = mcpScopes.ToList(); + if (mcpScopeList.Count > 0) + urls.Add(("Agent 365 Tools", Build(tenantId, blueprintClientId, McpConstants.Agent365ToolsIdentifierUri, mcpScopeList))); - var mcpScopeList = mcpScopes.ToList(); - if (mcpScopeList.Count > 0) - urls.Add(("Agent 365 Tools", Build(tenantId, blueprintClientId, McpConstants.Agent365ToolsIdentifierUri, mcpScopeList))); + urls.Add(("Messaging Bot API", Build(tenantId, blueprintClientId, ConfigConstants.MessagingBotApiIdentifierUri, new[] { ConfigConstants.MessagingBotApiAdminConsentScope }))); + } - urls.Add(("Messaging Bot API", Build(tenantId, blueprintClientId, ConfigConstants.MessagingBotApiIdentifierUri, new[] { ConfigConstants.MessagingBotApiAdminConsentScope }))); - urls.Add(("Observability API", Build(tenantId, blueprintClientId, ConfigConstants.ObservabilityApiIdentifierUri, new[] { ConfigConstants.ObservabilityApiOtelWriteScope }))); + // 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(("Power Platform API", Build(tenantId, blueprintClientId, PowerPlatformConstants.PowerPlatformApiIdentifierUri, new[] { PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead }))); return urls; } /// - /// Builds a single combined /v2.0/adminconsent URL covering all five required resources. - /// All scope tokens from all resources are joined with %20 into one scope parameter, - /// allowing a Global Administrator to grant consent with a single browser visit. + /// Builds a single combined /v2.0/adminconsent URL for the DW path only. + /// Covers Graph, MCP, Messaging Bot API, Observability API, and Power Platform API. + /// + /// Non-DW path: Observability API and Power Platform API are NOT included here. + /// The /v2.0/adminconsent endpoint requires scopes to be registered as + /// oauth2PermissionScopes on the resource SP in the tenant. These resource SPs are + /// not guaranteed to exist in all tenants, causing AADSTS650053. For non-DW, + /// admin consent for these APIs is handled programmatically via 'a365 setup admin'. /// internal static string BuildCombinedConsentUrl( string tenantId, string blueprintClientId, IEnumerable graphScopes, - IEnumerable mcpScopes) + IEnumerable mcpScopes, + bool isDw = true) { var allScopes = new List(); - foreach (var s in graphScopes) - allScopes.Add($"{AuthenticationConstants.MicrosoftGraphResourceUri}/{s}"); - foreach (var s in mcpScopes) - allScopes.Add($"{McpConstants.Agent365ToolsIdentifierUri}/{s}"); - allScopes.Add($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}"); - allScopes.Add($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"); + if (isDw) + { + foreach (var s in graphScopes) + allScopes.Add($"{AuthenticationConstants.MicrosoftGraphResourceUri}/{s}"); + foreach (var s in mcpScopes) + allScopes.Add($"{McpConstants.Agent365ToolsIdentifierUri}/{s}"); + allScopes.Add($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}"); + allScopes.Add($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiAdminConsentScope}"); + } allScopes.Add($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}"); return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes); } + /// + /// Populates per-resource consent URLs in config and sets + /// when the running account is not a Global Administrator. Called by both DW and non-DW setup paths + /// after the batch permissions step. + /// When is false, only Observability API and Power Platform API URLs are + /// generated — Graph, MCP, and Messaging Bot API are excluded. + /// No-op if admin consent was already granted or blueprint ID is absent. + /// + internal static void ApplyConsentUrlsIfNeeded( + SetupContext ctx, + string mcpResourceAppId, + IEnumerable graphScopes, + IEnumerable mcpScopes, + bool isDw = true) + { + if (ctx.Results.AdminConsentGranted || string.IsNullOrWhiteSpace(ctx.Config.AgentBlueprintId)) + return; + + var consentResourceNames = PopulateAdminConsentUrls(ctx.Config, mcpResourceAppId, mcpScopes, isDw); + ctx.Results.ConsentUrlsSavedToPath = ctx.GeneratedConfigPath; + ctx.Results.ConsentResourceNames.AddRange(consentResourceNames); + ctx.Results.CombinedConsentUrl = BuildCombinedConsentUrl( + ctx.Config.TenantId!, ctx.Config.AgentBlueprintId!, + graphScopes, mcpScopes, isDw); + } + /// /// Displays the setup summary for 'a365 setup admin' — shows grant results and /// a Graph Explorer query the administrator can use to verify the grants. @@ -380,19 +720,15 @@ public static void DisplayAdminSetupSummary( logger.LogInformation("Admin Setup Summary"); logger.LogInformation(""); - var adminStep = 0; - - if (results.AdminConsentGranted) - { - adminStep++; - logger.LogInformation(DryRunRow(adminStep, "Permission grants") + "ok (tenant-wide)"); - } - + // Numbered step rows — mirrors the setup admin dry-run + logger.LogInformation(DryRunRow(1, "Prerequisites") + "ok"); + if (!string.IsNullOrWhiteSpace(blueprintSpObjectId)) + logger.LogInformation(DryRunRow(2, "Blueprint") + "resolved (SP: {SpObjectId})", blueprintSpObjectId); + else + logger.LogInformation(DryRunRow(2, "Blueprint") + "resolved"); + logger.LogInformation(DryRunRow(3, "Permission Grants") + (results.AdminConsentGranted ? "ok" : "failed")); if (results.S2SAppRoleGranted == true) - { - adminStep++; - logger.LogInformation(DryRunRow(adminStep, "S2S app role") + "ok ({Scope})", ConfigConstants.ObservabilityApiOtelWriteScope); - } + logger.LogInformation(DryRunRow(4, "S2S app role") + "ok ({Scope})", ConfigConstants.ObservabilityApiOtelWriteScope); if (results.Errors.Count > 0) { @@ -407,15 +743,14 @@ public static void DisplayAdminSetupSummary( logger.LogInformation(""); logger.LogInformation("Warnings:"); foreach (var warning in results.Warnings) - logger.LogInformation(" {Warning}", warning); + logger.LogWarning(" {Warning}", warning); } - logger.LogInformation(""); - if (!string.IsNullOrWhiteSpace(blueprintSpObjectId)) { - logger.LogInformation("Verify OAuth2 grants in Graph Explorer:"); - logger.LogInformation(" GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '{BlueprintSpObjectId}'", blueprintSpObjectId); + logger.LogInformation(""); + logger.LogInformation("Verify grants:"); + logger.LogInformation(" GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '{SpObjectId}'", blueprintSpObjectId); } logger.LogInformation(""); @@ -428,6 +763,63 @@ public static void DisplayAdminSetupSummary( logger.LogInformation("Admin setup completed successfully"); } + /// + /// Prints the dry-run plan for the Digital Worker (--aiteammate true) path of setup all. + /// + internal static void PrintDwSetupAllDryRunPlan( + ILogger logger, + bool skipInfrastructure, + bool skipRequirements, + string[] rawArgs, + Agent365Config? config = null) + { + var sub = new string(' ', DryRunValCol); + + var cmdArgs = string.Join(' ', rawArgs.Where(a => !a.Equals("--dry-run", StringComparison.OrdinalIgnoreCase))); + logger.LogInformation("Dry run: a365 {Args} --dry-run", cmdArgs); + logger.LogInformation(""); + logger.LogInformation("The following steps would be performed."); + logger.LogInformation(""); + + // 1. Prerequisites + if (skipRequirements) + logger.LogInformation(DryRunRow(1, "Prerequisites") + "skip (--skip-requirements)"); + else + logger.LogInformation(DryRunRow(1, "Prerequisites") + "validate (PowerShell modules, Azure CLI)"); + + // 2. Azure hosting + if (skipInfrastructure) + logger.LogInformation(DryRunRow(2, "Azure hosting") + "skip — no Azure deployment configured"); + else + logger.LogInformation(DryRunRow(2, "Azure hosting") + "provision (Resource Group, App Service Plan, Web App)"); + + // 3. Blueprint — context-aware when config is available + if (!string.IsNullOrWhiteSpace(config?.AgentBlueprintId)) + { + PrintDryRunBlueprintReuseRows(logger, config.AgentBlueprintId!, step: 3); + } + else + { + logger.LogInformation(DryRunRow(3, "Blueprint") + "create (multi-tenant)"); + logger.LogInformation(sub + "create service principal"); + logger.LogInformation(sub + "create client secret"); + logger.LogInformation(sub + "create federated identity credential (FIC)"); + logger.LogInformation(sub + "create managed identity"); + } + + // 4. Inheritable Permissions + logger.LogInformation(DryRunRow(4, "Inheritable Permissions") + "configure for Microsoft Graph, Agent 365 Tools, Messaging Bot API, Observability API, Power Platform API"); + + // 5. Permission Grants + logger.LogInformation(DryRunRow(5, "Permission Grants") + "admin approval required — a365 setup admin --blueprint-id "); + + // 6. Project settings (DW has no Agent identity or Agent Registration steps) + logger.LogInformation(DryRunRow(6, "Project settings") + "write to appsettings.json"); + + logger.LogInformation(""); + logger.LogInformation("No changes will be made. Run without --dry-run to apply."); + } + /// /// Unified method to configure all permissions (OAuth2 grants, required resource access, inheritable permissions) for a resource /// @@ -530,7 +922,7 @@ public static async Task EnsureResourcePermissionsAsync( { throw new SetupValidationException( $"Failed to create/update OAuth2 permission grant from blueprint {config.AgentBlueprintId} to {resourceName} {resourceAppId}. " + - "This may be due to insufficient permissions. Ensure you have DelegatedPermissionGrant.ReadWrite.All or Application.ReadWrite.All permissions."); + "This may be due to insufficient permissions. Ensure you have DelegatedPermissionGrant.ReadWrite.All permission."); } // 3. Set inheritable permissions (for agent blueprints) @@ -552,7 +944,7 @@ public static async Task EnsureResourcePermissionsAsync( if (!ok && !alreadyExists) { throw new SetupValidationException($"Failed to set inheritable permissions: {err}. " + - "Ensure you have AgentIdentityBlueprint.UpdateAuthProperties.All and Application.ReadWrite.All permissions in your custom client app."); + "Ensure you have AgentIdentityBlueprint.UpdateAuthProperties.All permission in your custom client app."); } if (alreadyExists) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs index b29df131..b4932b15 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs @@ -11,6 +11,7 @@ public class SetupResults public bool InfrastructureCreated { get; set; } public bool BlueprintCreated { get; set; } public string? BlueprintId { get; set; } + public string? BlueprintDisplayName { get; set; } public bool McpPermissionsConfigured { get; set; } public bool BotApiPermissionsConfigured { get; set; } public bool MessagingEndpointRegistered { get; set; } @@ -101,9 +102,77 @@ public class SetupResults /// public string? CombinedConsentUrl { get; set; } + /// + /// Whether this is a non-DW blueprint setup flow (--aiteammate false). + /// Used in the summary display to show the correct recovery actions. + /// + public bool IsNonDwBlueprintFlow { get; set; } + + /// + /// Whether Principal-scoped oauth2PermissionGrants were successfully created for the agent identity. + /// Set in the non-DW non-admin path as an alternative to tenant-wide AllPrincipals consent. + /// + public bool AgentIdentityPermissionsGranted { get; set; } + + /// + /// Whether the Agent Identity was successfully created via the Agent Identity Graph API. + /// Populated by the non-DW blueprint setup flow only. + /// + public bool AgentIdentityCreated { get; set; } + + /// + /// The Agent Identity ID returned after agent identity creation. + /// Non-null when is true. + /// + public string? AgentIdentityId { get; set; } + + /// + /// The display name of the agent identity Entra app (e.g. "MyAgent Agent Identity"). + /// + public string? AgentIdentityDisplayName { get; set; } + + /// + /// Whether the Agent Instance was successfully registered via the Agent Instance Graph API. + /// Populated by the non-DW blueprint setup flow only. + /// + public bool AgentInstanceRegistered { get; set; } + + /// + /// The Agent Instance ID returned by the Agent Instance Graph API after registration. + /// Non-null when is true. + /// + public string? AgentInstanceId { get; set; } + + /// + /// The display name used when registering the agent in the Agent Registry (e.g. "MyAgent Agent"). + /// + public string? AgentRegistrationDisplayName { get; set; } + + + /// Whether step 1 (Requirements validation) was skipped via --skip-requirements. + public bool PrerequisitesSkipped { get; set; } + + /// Whether step 2 (Azure hosting) was skipped because no Azure deployment is configured. + public bool InfrastructureSkipped { get; set; } + + /// Whether step 3 (Blueprint creation) failed. Drives "failed"/"skipped" rows in the summary. + public bool BlueprintFailed { get; set; } + + /// Whether the blueprint service principal was created successfully. False means blueprint is partial. + public bool BlueprintServicePrincipalCreated { get; set; } + + /// Whether step 6 (Agent identity creation) was attempted but failed. + public bool AgentIdentityFailed { get; set; } + + /// Whether step 7 (Agent registration) was attempted but failed. + public bool AgentRegistrationFailed { get; set; } + + /// Whether step 8 (Project settings) was written to appsettings.json. + public bool ProjectSettingsWritten { get; set; } + public List Errors { get; } = new(); public List Warnings { get; } = new(); - + public bool HasErrors => Errors.Count > 0; public bool HasWarnings => Warnings.Count > 0; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs index 3385211f..2a838478 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs @@ -71,6 +71,13 @@ public static string[] GetRequiredRedirectUris(string clientAppId) return uris.ToArray(); } + /// + /// Well-known display name for the Agent 365 CLI client app registration in the tenant. + /// Used to resolve the clientAppId automatically when --agent-name is provided without a config file. + /// Tenants must register an Entra app with this exact display name and grant it the required permissions. + /// + public const string WellKnownClientAppDisplayName = "Agent 365 CLI"; + /// /// Application name for cache directory /// @@ -105,6 +112,14 @@ public static string[] GetRequiredRedirectUris(string clientAppId) /// public const string MicrosoftGraphResourceUri = "https://graph.microsoft.com"; + /// + /// OAuth2 v2 scope used to acquire a fresh Graph token via az CLI's scope-based + /// acquisition path. Requesting .default forces az CLI to bypass its resource-keyed + /// token cache and obtain a new access token from AAD that reflects the user's + /// current role assignments and consented permissions. + /// + public const string MicrosoftGraphDefaultScope = "https://graph.microsoft.com/.default"; + /// /// Redirect URI registered on the blueprint application to support the /v2.0/adminconsent flow. /// AAD requires at least one redirect URI on the application — AADSTS500113 is returned otherwise. @@ -144,11 +159,12 @@ public static string[] GetRequiredRedirectUris(string clientAppId) public const string DirectoryReadAllScope = "Directory.Read.All"; /// - /// Delegated scope for read/write access to Entra ID applications. - /// Used for FIC retrieval and deletion operations that are not yet covered by - /// more granular AgentIdentityBlueprint.* scopes. + /// Delegated scope required to create the Agent Blueprint service principal + /// (Agent Blueprint Principal) via POST /v1.0/servicePrincipals. + /// Per the Agent ID team (Kyle Marsh), AgentIdentityBlueprintPrincipal.Create is the correct + /// scope — AgentIdentityBlueprintPrincipal.ReadWrite.All alone returns 403. /// - public const string ApplicationReadWriteAllScope = "Application.ReadWrite.All"; + public const string AgentIdentityBlueprintPrincipalCreateScope = "AgentIdentityBlueprintPrincipal.Create"; /// /// Delegated scope required to delete an Agent Blueprint. @@ -156,6 +172,13 @@ public static string[] GetRequiredRedirectUris(string clientAppId) /// public const string AgentIdentityBlueprintDeleteRestoreAllScope = "AgentIdentityBlueprint.DeleteRestore.All"; + /// + /// Delegated scope required to delete an Agent Identity (service principal). + /// Per the Agent ID permissions reference, DELETE /beta/servicePrincipals/{id} for agent identities + /// requires this scope — NOT AgentIdentityBlueprint.DeleteRestore.All, which is blueprint-only. + /// + public const string AgentIdentityDeleteRestoreAllScope = "AgentIdentity.DeleteRestore.All"; + /// /// Delegated scope required to add or remove federated identity credentials and password credentials /// on an Agent Blueprint. Per the Agent ID permissions reference, covers keyCredentials, @@ -173,6 +196,14 @@ public static string[] GetRequiredRedirectUris(string clientAppId) /// public const string AgentIdentityBlueprintReadWriteAllScope = "AgentIdentityBlueprint.ReadWrite.All"; + /// + /// Delegated scope for full read/write access to Entra ID applications. + /// No longer in RequiredClientAppPermissions — replaced by AgentIdentityBlueprintPrincipal.Create + /// for blueprint SP creation per Agent ID team guidance. + /// Retained as a named constant for reference and potential future use. + /// + public const string ApplicationReadWriteAllScope = "Application.ReadWrite.All"; + /// /// Required delegated permissions for the custom client app used by a365 CLI. /// These permissions enable the CLI to manage Entra ID applications and agent blueprints. @@ -183,17 +214,28 @@ public static string[] GetRequiredRedirectUris(string clientAppId) /// public static readonly string[] RequiredClientAppPermissions = new[] { - "Application.ReadWrite.All", + "AgentIdentityBlueprintPrincipal.Create", // Required for POST /v1.0/servicePrincipals (blueprint SP creation) — per Agent ID team (Kyle Marsh) "AgentIdentityBlueprint.ReadWrite.All", "AgentIdentityBlueprint.UpdateAuthProperties.All", "AgentIdentityBlueprint.AddRemoveCreds.All", // Required for passwordCredentials and FICs during setup and cleanup "DelegatedPermissionGrant.ReadWrite.All", "Directory.Read.All", - "User.ReadWrite.All" // Required for agent user creation, usage location update, and license assignment - // Note: RoleManagementReadDirectoryScope and AgentIdentityBlueprint.DeleteRestore.All are - // intentionally excluded. DeleteRestore.All is a cleanup-only scope acquired on-demand via - // interactive consent during 'a365 cleanup'. RoleManagementReadDirectoryScope is excluded - // because Directory.Read.All already covers the needed read operations. + "AgentInstance.ReadWrite.All", // Required for POST /beta/agentRegistry/agentInstances (AdminSubcommand, PublishCommand) + // AgentRegistration.ReadWrite.All (resource: 00000003-0000-0000-c000-000000000000, ID: 20f263bf-7d50-4e66-912c-16b4b4194fd4) + // is required for POST/DELETE /stagingbeta/copilot/agentRegistrations. It is acquired via .default + // on the custom app token provider (not enumerated explicitly) to avoid AADSTS650053. + // This permission must be configured on the custom app via the portal but is not validated here + // because ClientAppValidator queries /v1.0/oauth2PermissionGrants which only returns consented + // delegated scopes in the same resource app bundle as the existing permissions. + // AgentIdentity.ReadWrite.All removed — no code requests it as a token scope. + // Delete uses AgentIdentity.DeleteRestore.All. Read uses AgentIdentity.Read.All. + // AgentIdentity.Create.All is a delegated scope used by CreateAgentIdentityDelegatedAsync + // (POST /beta/servicePrincipals/Microsoft.Graph.AgentIdentity). Requires Agent ID Developer role. + "AgentIdentityBlueprint.DeleteRestore.All", // Required for 'a365 cleanup' to delete the Agent Blueprint application + "AgentIdentity.DeleteRestore.All", // Required for 'a365 cleanup' to delete the Agent Identity service principal + "User.Read", // Required for /me endpoint to resolve the signed-in user's object ID for blueprint owner/sponsor assignment + "User.ReadWrite.All", // Required for agent user creation, usage location update, and license assignment + // Note: RoleManagementReadDirectoryScope is excluded because Directory.Read.All covers the needed read operations. }; /// @@ -205,7 +247,6 @@ public static string[] GetRequiredRedirectUris(string clientAppId) /// public static readonly string[] RequiredPermissionGrantScopes = new[] { - "Application.ReadWrite.All", "DelegatedPermissionGrant.ReadWrite.All", "AgentIdentityBlueprint.UpdateAuthProperties.All", }; @@ -221,6 +262,36 @@ public static string[] GetRequiredRedirectUris(string clientAppId) "AppRoleAssignment.ReadWrite.All", }; + /// + /// Scopes requested when acquiring an interactive Graph token for blueprint creation + /// and inheritable permissions configuration (used by InteractiveGraphAuthService). + /// Expressed as fully-qualified URIs as required by the Graph SDK credential constructor. + /// + public static readonly string[] BlueprintInteractiveAuthScopes = new[] + { + $"{MicrosoftGraphResourceUri}/AgentIdentityBlueprintPrincipal.Create", + $"{MicrosoftGraphResourceUri}/AgentIdentityBlueprint.ReadWrite.All", + $"{MicrosoftGraphResourceUri}/AgentIdentityBlueprint.UpdateAuthProperties.All", + $"{MicrosoftGraphResourceUri}/User.Read" + }; + + /// + /// Delegated scope for creating an Agent Identity (service principal) from a blueprint. + /// Used by POST /beta/servicePrincipals/Microsoft.Graph.AgentIdentity with agentIdentityBlueprintId. + /// Requires Agent ID Administrator, Agent ID Developer, or Global Administrator role. + /// This path does NOT require a blueprint client secret. + /// AgentIdentity.Create.All is required — AgentIdentity.ReadWrite.All alone is NOT sufficient + /// (confirmed via Graph Explorer: the endpoint returns 403 without Create.All in the scp claim). + /// + public const string AgentIdentityCreateAllScope = "AgentIdentity.Create.All"; + + /// + /// Delegated scope for creating and managing agent instances in the Microsoft Agent Registry. + /// Required for POST /beta/agentRegistry/agentInstances. + /// Requires the "Agent Registry Administrator" Entra role. + /// + public const string AgentInstanceReadWriteAllScope = "AgentInstance.ReadWrite.All"; + /// /// Environment variable name for bearer token used in local development. /// This token is stored in .env files (Python/Node.js) or launchSettings.json (.NET) @@ -228,9 +299,31 @@ public static string[] GetRequiredRedirectUris(string clientAppId) /// public const string BearerTokenEnvironmentVariable = "BEARER_TOKEN"; + /// + /// Application ID of the AgentX service (private preview Agent Registration API V2). + /// + public const string AgentXAppId = "59eca866-2f46-40b8-96ff-63f663121ef9"; + + /// + /// Resource URI for the AgentX service (private preview Agent Registration API V2). + /// Used with 'az account get-access-token --resource' to acquire a bearer token. + /// + public const string AgentXResource = $"api://{AgentXAppId}"; + + /// + /// Base URL for the AgentX service (private preview Agent Registration API V2 endpoint). + /// + public const string AgentXBaseUrl = "https://agentxppe.microsoft.com"; + + /// + /// Delegated scope for the AgentX Agent Registration API V2. + /// This scope must be consented on the custom client app to use the V2 registration endpoint. + /// + public const string AgentXAccessScope = $"api://{AgentXAppId}/AgentX.Access"; + /// /// Returns the per-server bearer token env var name for a given MCP server unique name. - /// e.g. "mcp_WordServer" → "BEARER_TOKEN_MCP_WORDSERVER" + /// e.g. "mcp_WordServer" -> "BEARER_TOKEN_MCP_WORDSERVER" /// Takes precedence over for V2 per-audience tokens. /// public static string GetPerServerBearerTokenEnvVar(string serverUniqueName) => @@ -250,4 +343,20 @@ public static string GetPerServerBearerTokenEnvVar(string serverUniqueName) => /// Device code flow may succeed depending on your tenant's Conditional Access Policy configuration. /// public const string DeviceCompliancePolicyBlockedError = "AADSTS53000"; + + /// + /// Windows Account Manager (WAM) error prefix for authentication failures. + /// WAM errors (e.g. 0xcaa90019) surface when Conditional Access Policy or device compliance + /// policies block the WAM broker flow. Device code flow bypasses the WAM broker and may succeed. + /// + public const string WamErrorPrefix = "0xcaa"; + + /// + /// WAM error code for "Need admin approval" (admin consent not granted). + /// This error means the client app's oauth2PermissionGrant is per-user (Principal) only, + /// not tenant-wide (AllPrincipals). Do NOT fall back to device code for this error — + /// device code will show the same browser page and hang if the user returns without consenting. + /// Instead, print the admin consent URL and exit cleanly. + /// + public const string WamConsentRequiredError = "0xcaa90019"; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index 1f36015f..4a11d089 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -77,12 +77,27 @@ 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 alongside "user_impersonation" to all provisioned agent identities. + /// Granted to all provisioned agent identities via OAuth2PermissionGrants. /// public const string ObservabilityApiOtelWriteScope = "Agent365.Observability.OtelWrite"; + /// + /// Delegated scope value exposed on the blueprint app registration to enable + /// OBO (On-Behalf-Of) callers to acquire tokens scoped to the agent. + /// + public const string BlueprintOboScope = "access_agent_as_user"; + /// /// Production deployment environment /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/AzureExceptions.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/AzureExceptions.cs index 45e64cc0..8d74b6d5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/AzureExceptions.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/AzureExceptions.cs @@ -123,7 +123,7 @@ public GraphApiException(string operation, string reason, bool isPermissionIssue ? new List { "Ensure you have the required Graph API permissions", - "You need Application.ReadWrite.All permission for agent blueprint creation", + "You need AgentIdentityBlueprint.ReadWrite.All permission for agent blueprint creation", "Contact your tenant administrator to grant permissions", $"See documentation: {ConfigConstants.CustomClientAppRegistrationUrl}" } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs index 28d58d7a..73cfa675 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs @@ -81,31 +81,53 @@ public static ClientAppValidationException MissingPermissions( /// /// Creates exception for missing admin consent. + /// Includes a direct admin consent URL that a Global Administrator can open to grant consent. /// - public static ClientAppValidationException MissingAdminConsent(string clientAppId) + public static ClientAppValidationException MissingAdminConsent(string clientAppId, string? tenantId = null) { + var consentUrl = BuildAdminConsentUrl(clientAppId, tenantId); + var consentInstruction = consentUrl != null + ? $"Share this URL with a Global Administrator to grant consent:\n {consentUrl}" + : "Grant admin consent at: Azure Portal > App registrations > Your app > API permissions."; + return new ClientAppValidationException( issueDescription: "Admin consent not granted for client app", errorDetails: new List { - "The required permissions are configured but admin consent is missing", - "Admin consent must be granted by a Global Administrator" + "The required permissions are configured but admin consent (AllPrincipals) is missing.", + "A per-user consent grant is not sufficient — all users in the tenant need access.", + "Admin consent must be granted by a Global Administrator." }, mitigationSteps: new List { - "Grant admin consent at: Azure Portal > App registrations > Your app > API permissions.", - "Click 'Grant admin consent for [Your Tenant]' and wait for propagation.", - "Confirm the consent dialog when prompted.", - "Verify the status shows 'Granted for [Your Tenant]' with a green checkmark.", - "Wait a few minutes for consent to propagate through Azure AD.", + consentInstruction, + "Alternatively: Azure Portal > App registrations > Your app > API permissions > Grant admin consent.", + "After consent is granted, re-run 'a365 setup requirements' to verify.", $"See setup guide: {ConfigConstants.Agent365CliDocumentationUrl}" }, context: new Dictionary { - ["clientAppId"] = clientAppId + ["clientAppId"] = clientAppId, + ["adminConsentUrl"] = consentUrl ?? string.Empty }); } + /// + /// Builds the admin consent URL for the given client app and tenant. + /// A Global Administrator can open this URL to grant tenant-wide (AllPrincipals) consent. + /// + public static string? BuildAdminConsentUrl(string clientAppId, string? tenantId) + { + if (string.IsNullOrWhiteSpace(clientAppId) || string.IsNullOrWhiteSpace(tenantId)) + return null; + + // Standard native-app redirect URI accepted by Entra ID for admin consent flows + const string redirectUri = "https://login.microsoftonline.com/common/oauth2/nativeclient"; + var clientIdEncoded = Uri.EscapeDataString(clientAppId); + var redirectUriEncoded = Uri.EscapeDataString(redirectUri); + return $"https://login.microsoftonline.com/{tenantId}/adminconsent?client_id={clientIdEncoded}&redirect_uri={redirectUriEncoded}"; + } + /// /// Creates exception for when the Azure token was revoked by a security event (CAE). /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs index 3fe42de5..2dfc582e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs @@ -24,6 +24,19 @@ public static class ProjectSettingsSyncHelper // Messaging Bot API Application GUID private const string DEFAULT_SERVICE_CONNECTION_SCOPE = $"{ConfigConstants.MessagingBotApiAppId}/.default"; + /// + /// Overload that accepts a pre-built config (e.g. from ctx.Config in the setup orchestrator) + /// so that in-memory values such as AgentDescription and AgentIdentityDisplayName derived + /// from --agent-name are not lost when the config is reloaded from disk. + /// + public static Task ExecuteAsync( + string a365ConfigPath, + Agent365Config config, + PlatformDetector platformDetector, + ILogger logger) + => ExecuteAsyncCore(a365ConfigPath, config, platformDetector, logger); + + public static async Task ExecuteAsync( string a365ConfigPath, string a365GeneratedPath, @@ -35,14 +48,32 @@ ILogger logger if (!File.Exists(a365GeneratedPath)) throw new FileNotFoundException("a365.generated.config.json not found", a365GeneratedPath); - // Load merged config via ConfigService var pkgConfig = await configService.LoadAsync(a365ConfigPath, a365GeneratedPath); + await ExecuteAsync(a365ConfigPath, pkgConfig, platformDetector, logger); + } + private static async Task ExecuteAsyncCore( + string a365ConfigPath, + Agent365Config pkgConfig, + PlatformDetector platformDetector, + ILogger logger + ) + { var project = pkgConfig.DeploymentProjectPath; if (string.IsNullOrWhiteSpace(project) || !Directory.Exists(project)) { - logger.LogWarning("deploymentProjectPath is not set or does not exist in a365.config.json; skipping project settings sync."); - return; + // Fall back to the directory of the config file (bootstrap mode: no deploymentProjectPath). + var configDir = Path.GetDirectoryName(Path.GetFullPath(a365ConfigPath)); + if (!string.IsNullOrWhiteSpace(configDir) && Directory.Exists(configDir)) + { + project = configDir; + logger.LogDebug("deploymentProjectPath not configured; using config directory: {Path}", project); + } + else + { + logger.LogWarning("deploymentProjectPath is not set or does not exist in a365.config.json; skipping project settings sync."); + return; + } } // Detect platform type (DotNet -> NodeJs -> Python -> Unknown) @@ -99,7 +130,7 @@ ILogger logger } } - logger.LogInformation("Stamped TenantId, ServiceConnection, and AgentBlueprint settings into {ProjectPath}", project); + logger.LogInformation("Stamped TenantId, ServiceConnection, AgentBlueprint, and Agent365Observability settings into {ProjectPath}", project); } /// @@ -475,15 +506,32 @@ static JsonObject RequireObj(JsonObject parent, string prop) // -- Agent365Observability -- root["EnableAgent365Exporter"] ??= false; - var obsSection = RequireObj(root, "Agent365Observability"); - if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintId)) + var obsAgentId = ResolveObservabilityAgentId(pkgConfig); + if (!string.IsNullOrWhiteSpace(obsAgentId) || !string.IsNullOrWhiteSpace(pkgConfig.TenantId)) { - obsSection["AgentBlueprintId"] = pkgConfig.AgentBlueprintId; + var obs = RequireObj(root, "Agent365Observability"); + if (!string.IsNullOrWhiteSpace(obsAgentId)) + obs["AgentId"] = obsAgentId; + if (!string.IsNullOrWhiteSpace(pkgConfig.AgentIdentityDisplayName)) + obs["AgentName"] = pkgConfig.AgentIdentityDisplayName; + if (!string.IsNullOrWhiteSpace(pkgConfig.AgentDescription)) + obs["AgentDescription"] = pkgConfig.AgentDescription; + if (!string.IsNullOrWhiteSpace(pkgConfig.TenantId)) + obs["TenantId"] = pkgConfig.TenantId; + if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintId)) + { + obs["AgentBlueprintId"] = pkgConfig.AgentBlueprintId; + obs["ClientId"] = pkgConfig.AgentBlueprintId; + } + if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintClientSecret)) + { + var obsSecret = SecretProtectionHelper.UnprotectSecret( + pkgConfig.AgentBlueprintClientSecret, + pkgConfig.AgentBlueprintClientSecretProtected, + logger); + obs["ClientSecret"] = obsSecret; + } } - if (!string.IsNullOrWhiteSpace(pkgConfig.TenantId)) - obsSection["TenantId"] = pkgConfig.TenantId; - obsSection["AgentName"] ??= ""; - obsSection["AgentDescription"] ??= ""; var updated = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(appsettingsPath, updated, new UTF8Encoding(false)); @@ -540,6 +588,20 @@ void Set(string key, string? value) // --- Agent365 Observability --- Set("ENABLE_A365_OBSERVABILITY_EXPORTER", "false"); + Set("AGENT365OBSERVABILITY__AGENTID", ResolveObservabilityAgentId(pkgConfig)); + Set("AGENT365OBSERVABILITY__AGENTNAME", pkgConfig.AgentIdentityDisplayName); + Set("AGENT365OBSERVABILITY__AGENTDESCRIPTION", pkgConfig.AgentDescription); + Set("AGENT365OBSERVABILITY__TENANTID", pkgConfig.TenantId); + Set("AGENT365OBSERVABILITY__AGENTBLUEPRINTID", pkgConfig.AgentBlueprintId); + Set("AGENT365OBSERVABILITY__CLIENTID", pkgConfig.AgentBlueprintId); + if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintClientSecret)) + { + var obsSecretPy = SecretProtectionHelper.UnprotectSecret( + pkgConfig.AgentBlueprintClientSecret, + pkgConfig.AgentBlueprintClientSecretProtected, + logger); + Set("AGENT365OBSERVABILITY__CLIENTSECRET", obsSecretPy); + } await File.WriteAllLinesAsync(envPath, lines, new UTF8Encoding(false)); } @@ -595,10 +657,33 @@ void Set(string key, string? value) // --- Agent365 Observability --- Set("ENABLE_A365_OBSERVABILITY_EXPORTER", "false"); + Set("agent365Observability__agentId", ResolveObservabilityAgentId(pkgConfig)); + Set("agent365Observability__agentName", pkgConfig.AgentIdentityDisplayName); + Set("agent365Observability__agentDescription", pkgConfig.AgentDescription); + Set("agent365Observability__tenantId", pkgConfig.TenantId); + Set("agent365Observability__agentBlueprintId", pkgConfig.AgentBlueprintId); + Set("agent365Observability__clientId", pkgConfig.AgentBlueprintId); + if (!string.IsNullOrWhiteSpace(pkgConfig.AgentBlueprintClientSecret)) + { + var obsSecretNode = SecretProtectionHelper.UnprotectSecret( + pkgConfig.AgentBlueprintClientSecret, + pkgConfig.AgentBlueprintClientSecretProtected, + logger); + Set("agent365Observability__clientSecret", obsSecretNode); + } await File.WriteAllLinesAsync(envPath, lines, new UTF8Encoding(false)); } + /// + /// Returns the Agent Identity app ID (non-DW) or the Blueprint app ID (DW) for use + /// as the AgentId field in the Agent365Observability config section. + /// + private static string? ResolveObservabilityAgentId(Agent365Config pkgConfig) => + !string.IsNullOrWhiteSpace(pkgConfig.AgenticAppId) + ? pkgConfig.AgenticAppId + : pkgConfig.AgentBlueprintId; + private static string EscapeEnv(string value) { // Keep as-is unless contains spaces or special chars; then quote diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index d4001b17..aae8ea7b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -36,23 +36,16 @@ public List Validate() ValidateGuid(ClientAppId, nameof(ClientAppId), errors); } - if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); - if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); - if (NeedDeployment) { + if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); + if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); if (string.IsNullOrWhiteSpace(Location)) errors.Add("location is required."); if (string.IsNullOrWhiteSpace(AppServicePlanName)) errors.Add("appServicePlanName is required."); if (string.IsNullOrWhiteSpace(WebAppName)) errors.Add("webAppName is required."); + if (string.IsNullOrWhiteSpace(DeploymentProjectPath)) errors.Add("deploymentProjectPath is required."); } - else - { - if (string.IsNullOrWhiteSpace(MessagingEndpoint)) - errors.Add("messagingEndpoint is required when needDeployment is 'no'."); - } - if (string.IsNullOrWhiteSpace(AgentIdentityDisplayName)) errors.Add("agentIdentityDisplayName is required."); - if (string.IsNullOrWhiteSpace(DeploymentProjectPath)) errors.Add("deploymentProjectPath is required."); // Validate custom blueprint permissions if (CustomBlueprintPermissions != null && CustomBlueprintPermissions.Count > 0) @@ -83,6 +76,25 @@ public List Validate() return errors; } + /// + /// Minimal validation for the config-free non-DW bootstrap path (--agent-name flow). + /// Only requires TenantId, ClientAppId, and AgentIdentityDisplayName. + /// SubscriptionId, ResourceGroup, DeploymentProjectPath, and MessagingEndpoint are not required. + /// + public List ValidateNonDwMinimal() + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(TenantId)) errors.Add("tenantId is required."); + if (string.IsNullOrWhiteSpace(ClientAppId)) + errors.Add($"clientAppId could not be resolved. Ensure an Entra app named \"{AuthenticationConstants.WellKnownClientAppDisplayName}\" exists in your tenant."); + else + ValidateGuid(ClientAppId, nameof(ClientAppId), errors); + if (string.IsNullOrWhiteSpace(AgentIdentityDisplayName)) errors.Add("agentIdentityDisplayName is required."); + + return errors; + } + /// /// Helper method to validate GUID format /// @@ -199,8 +211,70 @@ private static void ValidateGuid(string value, string fieldName, List er #endregion + #region Azure OpenAI Configuration + + /// + /// Name of the Azure OpenAI resource to create (non-AI Teammate agents only). + /// If set and NeedAzureOpenAI is true, setup will provision this resource. + /// + [JsonPropertyName("azureOpenAIName")] + public string? AzureOpenAIName { get; init; } + + /// + /// Azure region for the OpenAI resource. Defaults to Location if not set. + /// OpenAI resource availability varies by region. + /// + [JsonPropertyName("azureOpenAILocation")] + public string? AzureOpenAILocation { get; init; } + + /// + /// Name of the model deployment to create inside the Azure OpenAI resource (e.g., "gpt-4.1"). + /// + [JsonPropertyName("azureOpenAIModelDeploymentName")] + public string? AzureOpenAIModelDeploymentName { get; init; } + + /// + /// When true, setup will provision an Azure OpenAI resource. + /// Only relevant for non-AI Teammate agent deployments. + /// + [JsonPropertyName("needAzureOpenAI")] + public bool NeedAzureOpenAI { get; init; } + + #endregion + #region Agent Configuration + /// + /// Controls which setup and publish flow is used. + /// true (default) = Digital Worker (Agent Identity Blueprint pattern). + /// false = non-AI Teammate agent. Two variants are available when false: + /// - UseBlueprint = false: App Registration + Azure Bot, no blueprint. + /// - UseBlueprint = true: Blueprint-based non-DW flow (Agent Identity Blueprint + Agent Instance). + /// Can be overridden per-command with the --aiteammate flag. + /// + [JsonPropertyName("aiTeammate")] + public bool? AiTeammate { get; init; } + + /// + /// When true, use the blueprint-based non-DW flow (Agent Identity Blueprint + Agent Instance). + /// Only meaningful when AiTeammate is false. + /// Can be overridden per-command with the --use-blueprint flag. + /// + [JsonPropertyName("useBlueprint")] + public bool? UseBlueprint { get; init; } + + /// + /// Returns true when this config represents a non-AI Teammate agent deployment. + /// + [JsonIgnore] + public bool IsNonAiTeammate => AiTeammate == false; + + /// + /// Returns true when this config uses the blueprint-based non-DW flow. + /// + [JsonIgnore] + public bool IsNonDwBlueprint => AiTeammate == false && UseBlueprint == true; + /// /// Display name for the agent identity in Azure AD. /// @@ -373,6 +447,21 @@ public string BotName [JsonPropertyName("agentBlueprintId")] public string? AgentBlueprintId { get; set; } + /// + /// Unique identifier for the agent instance registered via the Agent Registry Graph API. + /// Set by 'a365 publish' for blueprint-based non-DW agents. + /// + [JsonPropertyName("agentInstanceId")] + public string? AgentInstanceId { get; set; } + + /// + /// Unique identifier returned by the AgentX Agent Registration API V2 + /// (POST https://agentxppe.microsoft.com/api/a365/agents/registration). + /// Stored separately from agentInstanceId which tracks the Graph agentRegistry instance. + /// + [JsonPropertyName("agentRegistrationId")] + public string? AgentRegistrationId { get; set; } + /// /// Azure AD object ID for the agent blueprint application. /// Used as authoritative identifier for all blueprint operations to handle cases @@ -442,6 +531,23 @@ public string BotName #endregion + #region Azure OpenAI State + + /// + /// Endpoint URL for the provisioned Azure OpenAI resource. + /// Set by setup, consumed by appsettings.generated.json output. + /// + [JsonPropertyName("azureOpenAIEndpoint")] + public string? AzureOpenAIEndpoint { get; set; } + + /// + /// API key for the provisioned Azure OpenAI resource. + /// + [JsonPropertyName("azureOpenAIApiKey")] + public string? AzureOpenAIApiKey { get; set; } + + #endregion + #region Consent State /// @@ -664,6 +770,12 @@ public Agent365Config WithCustomBlueprintPermissions(List(); rootCommand.AddCommand(ConfigCommand.CreateCommand(configLogger, wizardService: wizardService, clientAppValidator: clientAppValidator)); rootCommand.AddCommand(QueryEntraCommand.CreateCommand(queryEntraLogger, configService, executor, graphApiService, agentBlueprintService)); - rootCommand.AddCommand(CleanupCommand.CreateCommand(cleanupLogger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService, azureAuthValidator)); - rootCommand.AddCommand(PublishCommand.CreateCommand(publishLogger, configService, manifestTemplateService)); + rootCommand.AddCommand(CleanupCommand.CreateCommand(cleanupLogger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService, azureAuthValidator, graphApiService)); + rootCommand.AddCommand(PublishCommand.CreateCommand(publishLogger, configService, manifestTemplateService, graphApiService)); // Wrap all command handlers with exception handling // Build with middleware for global exception handling @@ -200,6 +200,23 @@ await Task.WhenAll( } }); + // Validate the configured clientAppId still exists in the tenant before any command runs. + // If not found, falls back to the well-known display name and patches a365.config.json. + // Skip for help/version requests — these never make Graph calls and must work offline. + var isHelpOrVersion = args.Length == 0 + || args.Any(a => a is "--help" or "-h" or "--version"); + if (!isHelpOrVersion) + { + try + { + await configService.TryResolveClientAppIdAsync(graphApiService); + } + catch (Exception ex) + { + startupLogger.LogDebug(ex, "Client app ID pre-resolution skipped: {Message}", ex.Message); + } + } + var parser = builder.Build(); return await parser.InvokeAsync(args); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index 9843d762..55e1e28e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -79,7 +79,7 @@ public async Task RunAsync( } catch (Exception ex) { - _logger.LogWarning(ex, "[WARN] Could not parse existing generated config; starting fresh"); + _logger.LogWarning(ex, "Could not parse existing generated config; starting fresh"); } } @@ -175,21 +175,19 @@ string GetConfig(string name) => if (string.IsNullOrWhiteSpace(agenticAppId)) { - // Create new agent identity - var identityResult = await CreateAgentIdentityAsync( + // Create new agent identity via GraphApiService (shared with non-DW setup flow) + agenticAppId = await _graphService.CreateAgentIdentityAsync( tenantId, agentBlueprintId!, agentBlueprintClientSecret!, agentIdentityDisplayName, cancellationToken); - if (!identityResult.success) + if (string.IsNullOrWhiteSpace(agenticAppId)) { _logger.LogError("Failed to create agent identity"); return false; } - - agenticAppId = identityResult.identityId; SetInstanceField(instance, "AgenticAppId", agenticAppId); await SaveInstanceAsync(generatedConfigPath, instance, cancellationToken); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs index 587d402d..6eb196f4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs @@ -137,16 +137,16 @@ public virtual async Task DeleteAgentIdentityAsync( { _logger.LogInformation("Deleting agent identity application: {ApplicationId}", applicationId); - // Agent Identity deletion requires the same DeleteRestore scope as blueprint deletion. - var requiredScopes = new[] { AuthenticationConstants.AgentIdentityBlueprintDeleteRestoreAllScope }; + // Agent Identity deletion requires AgentIdentity.DeleteRestore.All — NOT the blueprint scope. + // DELETE /beta/servicePrincipals/{id} for agent identities uses the AgentIdentity permission family. + var requiredScopes = new[] { AuthenticationConstants.AgentIdentityDeleteRestoreAllScope }; - _logger.LogInformation("Acquiring access token with AgentIdentityBlueprint.DeleteRestore.All scope..."); + _logger.LogInformation("Acquiring access token with AgentIdentity.DeleteRestore.All scope..."); _logger.LogInformation("An authentication dialog will appear to complete sign-in."); - // Use the special servicePrincipals endpoint for deletion var deletePath = $"/beta/servicePrincipals/{applicationId}"; - // Use GraphDeleteAsync with the special scopes required for identity operations + // Use GraphDeleteAsync with the correct scope for agent identity deletion return await _graphApiService.GraphDeleteAsync( tenantId, deletePath, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs index fd38dd46..e77d2d43 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs @@ -124,9 +124,32 @@ public async Task GetAccessTokenAsync( } else { - _logger.LogDebug("Using cached authentication token for {ResourceUrl} (tenant: {TenantId})", - resourceUrl, tenantId); - return cachedToken.AccessToken; + // Validate UPN: cached token must be for the same user identity as the cache key. + // Prevents returning a guest/cross-app token stored under a member UPN key. + if (!string.IsNullOrWhiteSpace(userId)) + { + var tokenUpn = TryExtractUpnFromJwt(cachedToken.AccessToken); + if (!string.IsNullOrWhiteSpace(tokenUpn) && + !string.Equals(tokenUpn, userId, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug( + "Cached token is for user {TokenUser} but requested user is {RequestedUser}. Re-authenticating...", + tokenUpn, userId); + // Fall through to re-authenticate + } + else + { + _logger.LogDebug("Using cached authentication token for {ResourceUrl} (tenant: {TenantId})", + resourceUrl, tenantId); + return cachedToken.AccessToken; + } + } + else + { + _logger.LogDebug("Using cached authentication token for {ResourceUrl} (tenant: {TenantId})", + resourceUrl, tenantId); + return cachedToken.AccessToken; + } } } else @@ -143,9 +166,29 @@ public async Task GetAccessTokenAsync( } // Authenticate interactively with specific tenant and scopes - _logger.LogInformation("Authentication required for Work IQ Tools"); + _logger.LogDebug("Authentication required for Agent 365 Tools"); var token = await AuthenticateInteractivelyAsync(resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser, loginHint: userId); + // Validate the token identity before caching: if a userId was requested, + // ensure the returned token is actually for that user. WAM may return a + // guest/cross-app token for an account it considers "equivalent" (same Microsoft + // account in a different tenant). Caching the wrong token would cause silent + // failures on the next run. + if (!string.IsNullOrWhiteSpace(userId)) + { + var returnedUpn = TryExtractUpnFromJwt(token.AccessToken); + if (!string.IsNullOrWhiteSpace(returnedUpn) && + !string.Equals(returnedUpn, userId, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug( + "Authentication returned token for {ReturnedUser} but {RequestedUser} was requested. Not caching.", + returnedUpn, userId); + // Return the token as-is — it may still be valid for this call. + // Do not write it to cache under the userId key. + return token.AccessToken; + } + } + // Cache the token with the appropriate cache key await CacheTokenAsync(cacheKey, token); @@ -224,7 +267,7 @@ private async Task AuthenticateInteractivelyAsync( // This allows passing custom App IDs directly via config scope = resourceUrl.EndsWith("/.default", StringComparison.OrdinalIgnoreCase) ? resourceUrl - : $"{resourceUrl}/.default"; + : $"{resourceUrl.TrimEnd('/')}/.default"; _logger.LogDebug("Using custom resource for authentication: {Resource}", resourceUrl); } scopes = [scope]; @@ -243,9 +286,7 @@ private async Task AuthenticateInteractivelyAsync( if (useInteractiveBrowser) { // Use MsalBrowserCredential which handles WAM on Windows and browser on other platforms - _logger.LogInformation("Using interactive authentication..."); - _logger.LogInformation("Please sign in with your Microsoft account and grant consent for the requested permissions."); - _logger.LogInformation(""); + _logger.LogDebug("Using interactive authentication (browser/WAM)..."); credential = CreateBrowserCredential(effectiveClientId, effectiveTenantId, loginHint: loginHint); } @@ -624,6 +665,8 @@ protected virtual TokenCredential CreateDeviceCodeCredential(string clientId, st return upn.GetString(); if (doc.RootElement.TryGetProperty("preferred_username", out var pref) && !string.IsNullOrWhiteSpace(pref.GetString())) return pref.GetString(); + if (doc.RootElement.TryGetProperty("unique_name", out var uniqueName) && !string.IsNullOrWhiteSpace(uniqueName.GetString())) + return uniqueName.GetString(); } catch { } // Static helper — no logger access. Caller logs via ResolveLoginHintFromCacheAsync. return null; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs index f35890a7..cbd9728c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs @@ -96,24 +96,67 @@ public async Task EnsureValidClientAppAsync( var missingRedirectUris = await CollectMissingRedirectUrisAsync(clientAppId, tenantId, ct); var publicClientNeedsEnabling = await IsPublicClientFlowsDisabledAsync(clientAppId, tenantId, ct); + // Check whether the existing consent grant is per-user (Principal) rather than tenant-wide (AllPrincipals). + // A Principal grant only covers the specific admin who first consented; other users (e.g. developers + // running blueprint creation) see "Need admin approval" even though permissions are technically granted. + bool needsConsentUpgrade = await HasPrincipalOnlyConsentGrantAsync(clientAppId, tenantId, ct); + // Determine what mutations are needed bool hasMissingPermissions = missingPermissions.Count > 0; bool hasMissingRedirectUris = missingRedirectUris.Count > 0; bool needsPublicClientEnabled = publicClientNeedsEnabling; - bool hasPendingMutations = hasMissingPermissions || hasMissingRedirectUris || needsPublicClientEnabled; + bool hasPendingMutations = hasMissingPermissions || hasMissingRedirectUris || needsPublicClientEnabled || needsConsentUpgrade; // Prompt the user before making any changes (unless skipConfirmation or no confirmation provider) bool applyFixes = true; if (hasPendingMutations && _confirmationProvider != null && !skipConfirmation) { + // Check if the user has admin privileges before offering to make changes. + // Non-admin users cannot modify app registrations — skip the prompt and fail immediately + // with actionable guidance including the admin consent URL. + var (isAdmin, _) = await _graphApiService.CheckServicePrincipalCreationPrivilegesAsync(tenantId, ct); + if (!isAdmin) + { + _logger.LogDebug("User does not have admin privileges to modify app registration — skipping auto-provision prompt"); + var missingDetails = new List(); + if (hasMissingPermissions) + missingDetails.Add($"Missing permissions: {string.Join(", ", missingPermissions)}"); + if (hasMissingRedirectUris) + missingDetails.Add($"Missing redirect URIs: {string.Join(", ", missingRedirectUris)}"); + if (needsPublicClientEnabled) + missingDetails.Add("Public client flows ('Allow public client flows') must be enabled"); + var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId); + var steps = new List + { + "Next Steps — Global Administrator action required:", + " Option 1 — Run the CLI as a Global Administrator:", + " a365 setup requirements" + }; + if (consentUrl != null) + { + steps.Add(" Option 2 — Share this consent URL with your Global Administrator:"); + steps.Add($" {consentUrl}"); + } + throw new ClientAppValidationException( + issueDescription: "Client app configuration requires a Global Administrator", + errorDetails: missingDetails, + mitigationSteps: steps); + } + _logger.LogInformation("The following changes will be applied to app registration ({AppId}):", clientAppId); _logger.LogInformation(""); if (hasMissingPermissions) { - _logger.LogInformation(" - Add permissions and grant admin consent:"); + _logger.LogInformation(" - Add permissions and grant admin consent for all users:"); foreach (var perm in missingPermissions) _logger.LogInformation(" {Permission}", perm); } + if (needsConsentUpgrade) + { + _logger.LogInformation(" - Upgrade consent grant from per-user to tenant-wide (AllPrincipals)"); + _logger.LogInformation(" This allows all users in the tenant to use the CLI without individual consent prompts."); + _logger.LogInformation(" (Required for multi-user workflows: admin runs setup, developer runs blueprint creation)"); + } if (hasMissingRedirectUris) { _logger.LogInformation(" - Add redirect URIs:"); @@ -175,10 +218,15 @@ public async Task EnsureValidClientAppAsync( throw ClientAppValidationException.MissingPermissions(clientAppId, missingPermissions); } - // Step 4: Verify admin consent + // Step 3.7: Upgrade consent grant from per-user to tenant-wide if needed. + // Must run before ValidateAdminConsentAsync so the consentType check passes. + if (applyFixes && needsConsentUpgrade) + await UpgradeConsentGrantToAllPrincipalsAsync(clientAppId, tenantId, ct); + + // Step 4: Verify admin consent (requires AllPrincipals grant) if (!await ValidateAdminConsentAsync(clientAppId, tenantId, ct)) { - throw ClientAppValidationException.MissingAdminConsent(clientAppId); + throw ClientAppValidationException.MissingAdminConsent(clientAppId, tenantId); } // Step 5: Verify and fix redirect URIs @@ -586,7 +634,12 @@ private async Task TryExtendConsentGrantScopesAsync( var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId, $"/v1.0/oauth2PermissionGrants/{grantId}", - new { scope = updatedScope }, + new JsonObject + { + ["scope"] = updatedScope, + ["consentType"] = "AllPrincipals", + ["principalId"] = null + }, ct); if (patchSuccess) @@ -707,6 +760,128 @@ private async Task IsPublicClientFlowsDisabledAsync( } } + /// + /// Returns true if the client app has only per-user (consentType: "Principal") consent grants + /// and no tenant-wide (AllPrincipals) grant covering required permissions. + /// When true, users other than the consenting admin see "Need admin approval" during interactive auth. + /// + private async Task HasPrincipalOnlyConsentGrantAsync(string clientAppId, string tenantId, CancellationToken ct) + { + try + { + using var spDoc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", ct); + if (spDoc == null) return false; + + var spJson = JsonNode.Parse(spDoc.RootElement.GetRawText()); + var spObjectId = spJson?["value"]?.AsArray().FirstOrDefault()?.AsObject()["id"]?.GetValue(); + if (string.IsNullOrWhiteSpace(spObjectId)) return false; + + using var grantsDoc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct); + if (grantsDoc == null) return false; + + var grantsJson = JsonNode.Parse(grantsDoc.RootElement.GetRawText()); + var grants = grantsJson?["value"]?.AsArray(); + if (grants == null || grants.Count == 0) return false; + + bool hasAllPrincipals = false; + bool hasPrincipal = false; + + foreach (var grantNode in grants) + { + var grantObj = grantNode?.AsObject(); + var consentType = grantObj?["consentType"]?.GetValue(); + var scope = grantObj?["scope"]?.GetValue() ?? string.Empty; + + // Only consider grants that cover required CLI permissions + bool isRelevantGrant = AuthenticationConstants.RequiredClientAppPermissions + .Any(p => scope.Contains(p, StringComparison.OrdinalIgnoreCase)); + if (!isRelevantGrant) continue; + + if (string.Equals(consentType, "AllPrincipals", StringComparison.OrdinalIgnoreCase)) + hasAllPrincipals = true; + else if (string.Equals(consentType, "Principal", StringComparison.OrdinalIgnoreCase)) + hasPrincipal = true; + } + + // Upgrade needed only when there's a Principal grant covering CLI permissions but no AllPrincipals grant + return hasPrincipal && !hasAllPrincipals; + } + catch (Exception ex) + { + _logger.LogDebug("HasPrincipalOnlyConsentGrantAsync failed (non-fatal): {Message}", ex.Message); + return false; + } + } + + /// + /// Upgrades all per-user (consentType: "Principal") oauth2PermissionGrants that cover required + /// CLI permissions to tenant-wide (consentType: "AllPrincipals", principalId: null). + /// This ensures that any user in the tenant can authenticate without seeing "Need admin approval". + /// + private async Task UpgradeConsentGrantToAllPrincipalsAsync(string clientAppId, string tenantId, CancellationToken ct) + { + try + { + using var spDoc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", ct); + if (spDoc == null) return; + + var spJson = JsonNode.Parse(spDoc.RootElement.GetRawText()); + var spObjectId = spJson?["value"]?.AsArray().FirstOrDefault()?.AsObject()["id"]?.GetValue(); + if (string.IsNullOrWhiteSpace(spObjectId)) return; + + using var grantsDoc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct); + if (grantsDoc == null) return; + + var grantsJson = JsonNode.Parse(grantsDoc.RootElement.GetRawText()); + var grants = grantsJson?["value"]?.AsArray(); + if (grants == null) return; + + foreach (var grantNode in grants) + { + var grant = grantNode?.AsObject(); + if (grant == null) continue; + + var grantId = grant["id"]?.GetValue(); + var consentType = grant["consentType"]?.GetValue(); + var scope = grant["scope"]?.GetValue() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(grantId)) continue; + + // Only upgrade Principal grants that cover required CLI permissions + if (!string.Equals(consentType, "Principal", StringComparison.OrdinalIgnoreCase)) continue; + + bool isRelevantGrant = AuthenticationConstants.RequiredClientAppPermissions + .Any(p => scope.Contains(p, StringComparison.OrdinalIgnoreCase)); + if (!isRelevantGrant) continue; + + _logger.LogInformation("Upgrading consent grant from per-user to tenant-wide (AllPrincipals)..."); + + var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId, + $"/v1.0/oauth2PermissionGrants/{grantId}", + new JsonObject + { + ["consentType"] = "AllPrincipals", + ["principalId"] = null, + ["scope"] = scope + }, + ct); + + if (patchSuccess) + _logger.LogInformation("Consent grant upgraded to AllPrincipals — all tenant users can now authenticate without individual consent prompts."); + else + _logger.LogWarning("Failed to upgrade consent grant to AllPrincipals (may require Global Administrator role)."); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error upgrading consent grant (non-fatal): {Message}", ex.Message); + } + } + #region Private Helper Methods private async Task GetClientAppInfoAsync(string clientAppId, string tenantId, CancellationToken ct) @@ -807,9 +982,12 @@ private async Task> ValidatePermissionsConfiguredAsync( } else { - _logger.LogWarning("Could not resolve permission ID for: {PermissionName}", permissionName); - _logger.LogWarning("This permission may be a beta API or unavailable in your tenant. Validation cannot verify its presence."); - // Don't add to missing list - we can't verify it + // GUID not in v1.0 oauth2PermissionScopes (e.g. preview scopes like AgentIdentity.Create.All). + // Add to missing so EnsurePermissionsConfiguredAsync -> TryExtendConsentGrantScopesAsync + // patches the consent grant by scope name (no GUID required). The step-3.5 consent + // fallback will remove this entry if already granted. + _logger.LogDebug("Could not resolve permission GUID for {PermissionName} — will verify via consent grants", permissionName); + missingPermissions.Add(permissionName); } } @@ -995,7 +1173,18 @@ private async Task ValidateAdminConsentAsync(string clientAppId, string te if (grantsDoc == null) { _logger.LogDebug("Could not verify admin consent status"); - return true; // Best-effort check + _logger.LogWarning( + "Admin consent status could not be verified — insufficient permissions to read consent grants."); + _logger.LogWarning( + "If you see 'Need admin approval' during blueprint creation, admin consent has not been granted."); + var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId); + if (consentUrl != null) + { + _logger.LogWarning("A Global Administrator must either:"); + _logger.LogWarning(" 1. Run 'a365 setup requirements' with an admin account to auto-grant consent, OR"); + _logger.LogWarning(" 2. Open this URL to grant consent: {ConsentUrl}", consentUrl); + } + return true; // Best-effort — still allow developer to proceed } var grantsJson = JsonNode.Parse(grantsDoc.RootElement.GetRawText()); @@ -1006,27 +1195,80 @@ private async Task ValidateAdminConsentAsync(string clientAppId, string te return false; // No grants found - admin consent missing } - // Check if there's a grant for Microsoft Graph with required scopes - var hasGraphGrant = grants - .Select(grant => grant?.AsObject()) - .Select(grantObj => grantObj?["scope"]?.GetValue()) - .Where(scope => !string.IsNullOrWhiteSpace(scope)) - .Any(scope => + // Require a tenant-wide (AllPrincipals) grant. A per-user (Principal) grant only covers the + // specific admin who consented; other users see "Need admin approval" during interactive auth. + // Graph may split permissions across multiple grants (e.g. one per resource SP), so accumulate + // consented scopes across all AllPrincipals grants before comparing. + var consentedScopes = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var grant in grants) + { + var grantObj = grant?.AsObject(); + if (!string.Equals( + grantObj?["consentType"]?.GetValue(), + "AllPrincipals", + StringComparison.OrdinalIgnoreCase)) + continue; + + var scope = grantObj?["scope"]?.GetValue(); + if (string.IsNullOrWhiteSpace(scope)) continue; + + foreach (var s in scope!.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + consentedScopes.Add(s); + } + + var foundPermissions = AuthenticationConstants.RequiredClientAppPermissions + .Intersect(consentedScopes, StringComparer.OrdinalIgnoreCase) + .ToList(); + + bool hasAllPrincipalsGraphGrant; + if (foundPermissions.Count == AuthenticationConstants.RequiredClientAppPermissions.Length) + { + _logger.LogDebug("Admin consent (AllPrincipals) verified for all {Count} required permissions", foundPermissions.Count); + hasAllPrincipalsGraphGrant = true; + } + else + { + if (foundPermissions.Count > 0) { - var grantedScopes = scope!.Split(' ', StringSplitOptions.RemoveEmptyEntries); - var foundPermissions = AuthenticationConstants.RequiredClientAppPermissions - .Intersect(grantedScopes, StringComparer.OrdinalIgnoreCase) + var missingPermissions = AuthenticationConstants.RequiredClientAppPermissions + .Except(foundPermissions, StringComparer.OrdinalIgnoreCase) .ToList(); + _logger.LogDebug( + "Admin consent grants found but missing {MissingCount} permission(s): {Missing}", + missingPermissions.Count, + string.Join(", ", missingPermissions)); + } + hasAllPrincipalsGraphGrant = false; + } - if (foundPermissions.Count > 0) - { - _logger.LogDebug("Admin consent verified for {Count} permissions", foundPermissions.Count); - return true; - } - return false; - }); + if (!hasAllPrincipalsGraphGrant) + { + // Check if there's a Principal-only grant — surface a more specific actionable message + bool hasPrincipalGrant = grants + .Select(g => g?.AsObject()) + .Any(g => string.Equals(g?["consentType"]?.GetValue(), "Principal", StringComparison.OrdinalIgnoreCase)); + + if (hasPrincipalGrant) + { + _logger.LogWarning("Consent grant is per-user only (consentType: Principal). Tenant-wide (AllPrincipals) consent is required."); + } + else + { + _logger.LogWarning("No admin consent grant found for the required permissions."); + } + + // Print the admin consent URL so the user (or their admin) can fix this immediately + var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId); + if (consentUrl != null) + { + _logger.LogInformation("To grant tenant-wide admin consent, share this URL with a Global Administrator:"); + _logger.LogInformation(" {ConsentUrl}", consentUrl); + _logger.LogInformation("After consent is granted, re-run 'a365 setup requirements' to verify."); + _logger.LogInformation(""); + } + } - return hasGraphGrant; + return hasAllPrincipalsGraphGrant; } #endregion diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs index 16df7ea8..548b4c5d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs @@ -171,12 +171,8 @@ public static void WarnIfLocalGeneratedConfigIsStale(string? localPath, ILogger? var localTime = localUpdated.GetDateTime(); var globalTime = globalUpdated.GetDateTime(); - // Only warn if the content timestamps differ (meaning they're from different save operations) - // TODO: Current design uses local folder data even if it's older than %LocalAppData%. - // This needs to be revisited to determine if we should: - // 1. Always prefer %LocalAppData% as authoritative source - // 2. Prompt user to choose which config to use - // 3. Auto-sync from newer to older location + // Warn when the local config is older — the user may have newer state in the global + // directory from a previous CLI version that still wrote there. if (globalTime > localTime) { var msg = $"Warning: The local generated config (at {localPath}) is older than the global config (at {globalPath}). You may be using stale configuration. Consider syncing or running setup again."; @@ -370,66 +366,48 @@ public async Task SaveStateAsync( } } - // For relative paths, check if we're in a project directory (has local static config) - var staticConfigPath = Path.Combine(Environment.CurrentDirectory, ConfigConstants.DefaultConfigFileName); - bool hasLocalStaticConfig = File.Exists(staticConfigPath); - - if (hasLocalStaticConfig) + // Always save relative to the current directory. + // Global directory fallback has been removed — config is always project-local. + var currentDirPath = Path.Combine(Environment.CurrentDirectory, statePath); + try { - // We're in a project directory - save state locally only - // This ensures each project maintains its own independent configuration - var currentDirPath = Path.Combine(Environment.CurrentDirectory, statePath); - try - { - await File.WriteAllTextAsync(currentDirPath, json); - _logger?.LogDebug("Saved dynamic state to local project directory: {StatePath}", currentDirPath); - } - catch (Exception ex) - { - _logger?.LogError(ex, "Failed to save dynamic state to: {StatePath}", currentDirPath); - throw; - } + await File.WriteAllTextAsync(currentDirPath, json); + _logger?.LogDebug("Saved dynamic state to: {StatePath}", currentDirPath); } - else + catch (Exception ex) { - // Not in a project directory - save to global directory for portability - // This allows CLI commands to work when run from any directory - await SyncConfigToGlobalDirectoryAsync(statePath, json, throwOnError: true); - _logger?.LogDebug("Saved dynamic state to global directory (no local static config found)"); + _logger?.LogError(ex, "Failed to save dynamic state to: {StatePath}", currentDirPath); + throw; } } /// public async Task ValidateAsync(Agent365Config config) { - var errors = new List(); + // Required-field rules live in Agent365Config.Validate() — single source of truth. + var errors = new List(config.Validate()); var warnings = new List(); - ValidateRequired(config.TenantId, nameof(config.TenantId), errors); - ValidateGuid(config.TenantId, nameof(config.TenantId), errors); + // Format-only checks — run only when the value is present (required-field errors already above). + if (!string.IsNullOrWhiteSpace(config.TenantId)) + ValidateGuid(config.TenantId, nameof(config.TenantId), errors); if (config.NeedDeployment) { - // Validate required static properties - ValidateRequired(config.SubscriptionId, nameof(config.SubscriptionId), errors); - ValidateRequired(config.ResourceGroup, nameof(config.ResourceGroup), errors); - ValidateRequired(config.Location, nameof(config.Location), errors); - ValidateRequired(config.AppServicePlanName, nameof(config.AppServicePlanName), errors); - ValidateRequired(config.WebAppName, nameof(config.WebAppName), errors); - - // Validate GUID formats - ValidateGuid(config.SubscriptionId, nameof(config.SubscriptionId), errors); - - // Validate Azure naming conventions - ValidateResourceGroupName(config.ResourceGroup, errors); - ValidateAppServicePlanName(config.AppServicePlanName, errors); - ValidateWebAppName(config.WebAppName, errors); + if (!string.IsNullOrWhiteSpace(config.SubscriptionId)) + ValidateGuid(config.SubscriptionId, nameof(config.SubscriptionId), errors); + if (!string.IsNullOrWhiteSpace(config.ResourceGroup)) + ValidateResourceGroupName(config.ResourceGroup, errors); + if (!string.IsNullOrWhiteSpace(config.AppServicePlanName)) + ValidateAppServicePlanName(config.AppServicePlanName, errors); + if (!string.IsNullOrWhiteSpace(config.WebAppName)) + ValidateWebAppName(config.WebAppName, errors); } else { - // Only validate bot messaging endpoint - ValidateRequired(config.MessagingEndpoint, nameof(config.MessagingEndpoint), errors); - ValidateUrl(config.MessagingEndpoint, nameof(config.MessagingEndpoint), errors); + // MessagingEndpoint is optional; if provided it must be a valid URL. + if (!string.IsNullOrWhiteSpace(config.MessagingEndpoint)) + ValidateUrl(config.MessagingEndpoint, nameof(config.MessagingEndpoint), errors); } // Validate dynamic properties if they exist @@ -548,48 +526,148 @@ public async Task InitializeStateAsync(string statePath = "a365.generated.config #region Config File Resolution /// - /// Searches for a config file in multiple standard locations. + /// Searches for a config file in the current working directory only. + /// Global config directory lookup has been removed to prevent stale config in one + /// project directory from contaminating commands run in a different directory + /// (e.g. a leftover global a365.config.json interfering with --agent-name bootstrap). /// /// The config file name to search for - /// The full path to the config file if found, otherwise null + /// The full path to the config file if found in the current directory, otherwise null private static string? FindConfigFile(string fileName) { - // 1. Current directory var currentDirPath = Path.Combine(Environment.CurrentDirectory, fileName); - if (File.Exists(currentDirPath)) - return currentDirPath; - - // 2. Global config directory (use consistent path resolution) - var globalConfigPath = Path.Combine(GetGlobalConfigDirectory(), fileName); - if (File.Exists(globalConfigPath)) - return globalConfigPath; - - // Not found - return null; + return File.Exists(currentDirPath) ? currentDirPath : null; } - + /// - /// Gets the path to the static configuration file (a365.config.json). - /// Searches current directory first, then global config directory. + /// Gets the path to the static configuration file (a365.config.json) in the current directory. /// - /// Full path if found, otherwise null + /// Full path if found in the current directory, otherwise null public static string? GetConfigFilePath() { return FindConfigFile("a365.config.json"); } - + /// - /// Gets the path to the generated configuration file (a365.generated.config.json). - /// Searches current directory first, then global config directory. + /// Gets the path to the generated configuration file (a365.generated.config.json) in the current directory. /// - /// Full path if found, otherwise null + /// Full path if found in the current directory, otherwise null public static string? GetGeneratedConfigFilePath() { return FindConfigFile("a365.generated.config.json"); } + /// + public async Task TryResolveClientAppIdAsync(GraphApiService graphApiService, CancellationToken ct = default) + { + var configPath = GetConfigFilePath(); + if (configPath == null) + { + _logger?.LogDebug("No a365.config.json found — skipping client app ID resolution."); + return; + } + + try + { + var json = await File.ReadAllTextAsync(configPath, ct); + using var doc = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true }); + var root = doc.RootElement; + + root.TryGetProperty("tenantId", out var tenantIdEl); + root.TryGetProperty("clientAppId", out var clientAppIdEl); + var tenantId = tenantIdEl.ValueKind == JsonValueKind.String ? tenantIdEl.GetString() : null; + var configuredId = clientAppIdEl.ValueKind == JsonValueKind.String ? clientAppIdEl.GetString() : null; + + if (string.IsNullOrWhiteSpace(tenantId)) + { + _logger?.LogDebug("No tenantId in config — skipping client app ID resolution."); + return; + } + + // If a clientAppId is configured, validate it still exists. + if (!string.IsNullOrWhiteSpace(configuredId)) + { + var exists = await graphApiService.ApplicationExistsByAppIdAsync(tenantId, configuredId, ct); + if (exists) + { + _logger?.LogDebug("Configured clientAppId {Id} is valid.", configuredId); + return; + } + + _logger?.LogInformation( + "Configured clientAppId {Id} was not found in the tenant. Looking up by display name '{Name}'...", + configuredId, AuthenticationConstants.WellKnownClientAppDisplayName); + } + + // Look up by well-known display name. + var resolvedId = await graphApiService.FindApplicationByDisplayNameAsync( + tenantId, AuthenticationConstants.WellKnownClientAppDisplayName, ct); + + if (string.IsNullOrWhiteSpace(resolvedId)) + { + _logger?.LogDebug( + "No app named '{Name}' found — client app ID unresolved.", + AuthenticationConstants.WellKnownClientAppDisplayName); + return; + } + + if (string.Equals(resolvedId, configuredId, StringComparison.OrdinalIgnoreCase)) + { + _logger?.LogDebug("Resolved clientAppId matches configured value — no update needed."); + return; + } + + // Patch clientAppId in the JSON file preserving all other fields. + await PatchClientAppIdInConfigFileAsync(configPath, resolvedId, ct); + _logger?.LogInformation( + "clientAppId updated to {NewId} (found by display name '{Name}'). a365.config.json has been updated.", + resolvedId, AuthenticationConstants.WellKnownClientAppDisplayName); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger?.LogDebug(ex, "Client app ID resolution skipped due to error: {Message}", ex.Message); + } + } + + /// + /// Patches only the clientAppId field in a365.config.json, preserving all other fields and formatting. + /// Uses targeted regex replacement so JSON property order and any comments are kept intact. + /// Falls back to deserialize/re-serialize if the field is not found (e.g., first-time write). + /// + private static async Task PatchClientAppIdInConfigFileAsync(string configPath, string newClientAppId, CancellationToken ct) + { + var json = await File.ReadAllTextAsync(configPath, ct); + var escapedValue = JsonSerializer.Serialize(newClientAppId); // produces "\"value\"" + + // Replace the clientAppId value in-place, preserving property order and comments. + var patched = Regex.Replace( + json, + @"(""clientAppId""\s*:\s*)""[^""\\]*(?:\\.[^""\\]*)*""", + $"$1{escapedValue}", + RegexOptions.None); + + if (patched != json) + { + await File.WriteAllTextAsync(configPath, patched, ct); + return; + } + + // Field not present — fall back to deserialize/re-serialize (first-time write). + var dict = JsonSerializer.Deserialize>( + json, new JsonSerializerOptions { ReadCommentHandling = JsonCommentHandling.Skip }) + ?? throw new JsonException("Failed to parse config file for patching."); + + dict["clientAppId"] = JsonSerializer.SerializeToElement(newClientAppId); + var updated = JsonSerializer.Serialize(dict, new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(configPath, updated, ct); + } + #endregion - + #region Private Helper Methods /// @@ -768,14 +846,6 @@ private string GetCliVersion() #region Validation Helpers - private void ValidateRequired(string? value, string propertyName, List errors) - { - if (string.IsNullOrWhiteSpace(value)) - { - errors.Add($"{propertyName} is required but was not provided."); - } - } - private void ValidateGuid(string? value, string propertyName, List errors) { if (string.IsNullOrWhiteSpace(value)) return; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs index f0fa2829..26e1e1b4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs @@ -230,8 +230,8 @@ private static string ExtractDomainFromAccount(AzureAccountInfo accountInfo) // Step 11: Final confirmation to save configuration Console.Write("Save this configuration? (Y/n): "); var saveResponse = Console.ReadLine()?.Trim().ToLowerInvariant(); - - if (saveResponse == "n" || saveResponse == "no") + + if (saveResponse is null || saveResponse == "n" || saveResponse == "no") { Console.WriteLine("Configuration cancelled."); _logger.LogInformation("Configuration wizard cancelled by user"); @@ -408,7 +408,9 @@ private string PromptForDeploymentPath(Agent365Config? existingConfig) { Console.Write($"Select resource group [1-{resourceGroups.Count}] (default: {Math.Max(1, defaultIndex)}), or type a new resource group name: "); var input = Console.ReadLine()?.Trim(); - + if (input is null) + throw new OperationCanceledException(); + if (string.IsNullOrWhiteSpace(input)) { input = Math.Max(1, defaultIndex).ToString(); @@ -459,7 +461,9 @@ private string PromptForDeploymentPath(Agent365Config? existingConfig) { Console.Write($"Select option [1-{plansInRg.Count + 1}] (default: {Math.Max(1, defaultIndex)}): "); var input = Console.ReadLine()?.Trim(); - + if (input is null) + throw new OperationCanceledException(); + if (string.IsNullOrWhiteSpace(input)) { input = Math.Max(1, defaultIndex).ToString(); @@ -501,6 +505,8 @@ private string PromptForDeploymentPath(Agent365Config? existingConfig) if (string.IsNullOrWhiteSpace(defaultPlanName)) defaultPlanName = "agent365-plan"; Console.Write($"Enter a name for the new App Service Plan (default: {defaultPlanName}, or type 'cancel' to abort): "); var input = Console.ReadLine()?.Trim(); + if (input is null) + throw new OperationCanceledException(); if (string.IsNullOrWhiteSpace(input)) { input = defaultPlanName; @@ -1018,7 +1024,7 @@ private string GetUsageLocationFromAccount(AzureAccountInfo accountInfo) Console.WriteLine($"Please fix the issues and try again. (Attempt {attemptCount}/{maxAttempts})"); Console.WriteLine("Press Enter to retry, or type 'cancel' to abort setup."); var response = Console.ReadLine()?.Trim().ToLowerInvariant(); - if (response == "cancel") + if (response is null || response == "cancel") { return null; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs index 74f1cc35..be9ee206 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs @@ -16,7 +16,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// public sealed class DelegatedConsentService { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly GraphApiService _graphService; // Constants @@ -24,7 +24,7 @@ public sealed class DelegatedConsentService private const string AllPrincipalsConsentType = "AllPrincipals"; public DelegatedConsentService( - ILogger logger, + ILogger logger, GraphApiService graphService) { _logger = logger; @@ -48,11 +48,10 @@ public async Task EnsureBlueprintPermissionGrantAsync( { try { - _logger.LogInformation("==> Ensuring AgentIdentityBlueprint.ReadWrite.All permission for custom client app"); - _logger.LogInformation(""); - _logger.LogInformation(" Client App ID: {AppId}", callingAppId); - _logger.LogInformation(" Tenant ID: {TenantId}", tenantId); - _logger.LogInformation(" Required Scope: {Scope}", TargetScope); + _logger.LogInformation("Verifying consent for agent blueprint operations..."); + _logger.LogDebug(" Client App ID: {AppId}", callingAppId); + _logger.LogDebug(" Tenant ID: {TenantId}", tenantId); + _logger.LogDebug(" Required Scope: {Scope}", TargetScope); // Validate inputs if (!Guid.TryParse(callingAppId, out _)) @@ -68,7 +67,7 @@ public async Task EnsureBlueprintPermissionGrantAsync( } // Get Graph access token with required scopes - _logger.LogInformation("Acquiring Graph API access token..."); + _logger.LogDebug("Acquiring Graph API access token..."); var graphToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct: cancellationToken); if (string.IsNullOrWhiteSpace(graphToken)) { @@ -79,7 +78,7 @@ public async Task EnsureBlueprintPermissionGrantAsync( using var httpClient = HttpClientFactory.CreateAuthenticatedClient(graphToken, correlationId: correlationId); // Step 1: Get or create service principal for custom client app - _logger.LogInformation(" Looking up service principal for client app (ID: {AppId})", callingAppId); + _logger.LogDebug(" Looking up service principal for client app (ID: {AppId})", callingAppId); var clientSp = await GetOrCreateServicePrincipalAsync(httpClient, callingAppId, tenantId, cancellationToken); if (clientSp == null) { @@ -88,10 +87,10 @@ public async Task EnsureBlueprintPermissionGrantAsync( } var clientSpId = clientSp.RootElement.GetProperty("id").GetString()!; - _logger.LogInformation(" Client Service Principal ID: {SpId}", clientSpId); + _logger.LogDebug(" Client Service Principal ID: {SpId}", clientSpId); // Step 2: Get Microsoft Graph service principal - _logger.LogInformation(" Looking up Microsoft Graph service principal"); + _logger.LogDebug(" Looking up Microsoft Graph service principal"); var graphSp = await GetServicePrincipalAsync(httpClient, AuthenticationConstants.MicrosoftGraphResourceAppId, cancellationToken); if (graphSp == null) { @@ -100,15 +99,15 @@ public async Task EnsureBlueprintPermissionGrantAsync( } var graphSpId = graphSp.RootElement.GetProperty("id").GetString()!; - _logger.LogInformation(" Graph Service Principal ID: {SpId}", graphSpId); + _logger.LogDebug(" Graph Service Principal ID: {SpId}", graphSpId); // Step 3: Check if grant already exists - _logger.LogInformation(" Checking for existing permission grant"); + _logger.LogDebug(" Checking for existing permission grant"); var existingGrants = await GetExistingGrantsAsync(httpClient, clientSpId, graphSpId, cancellationToken); if (existingGrants != null && existingGrants.Count > 0) { - _logger.LogInformation(" Found {Count} existing grant(s)", existingGrants.Count); + _logger.LogDebug(" Found {Count} existing grant(s)", existingGrants.Count); // Update existing grant(s) to include required scope foreach (var grant in existingGrants) @@ -118,7 +117,7 @@ public async Task EnsureBlueprintPermissionGrantAsync( } else { - _logger.LogInformation(" No existing grants found, creating new grant"); + _logger.LogDebug(" No existing grants found, creating new grant"); // Create new grant with required scope var success = await CreateGrantAsync(httpClient, clientSpId, graphSpId, TargetScope, cancellationToken); @@ -129,8 +128,7 @@ public async Task EnsureBlueprintPermissionGrantAsync( } } - _logger.LogInformation("Successfully ensured grant for scope: {Scope}", TargetScope); - _logger.LogInformation(" You can now create Agent Blueprints"); + _logger.LogDebug("Consent verified for scope: {Scope}", TargetScope); return true; } @@ -166,12 +164,12 @@ public async Task EnsureBlueprintPermissionGrantAsync( var getSp = await GetServicePrincipalAsync(httpClient, appId, cancellationToken); if (getSp != null) { - _logger.LogInformation(" Service principal already exists for app {AppId}", appId); + _logger.LogDebug("Service principal already exists for app {AppId}", appId); return getSp; } // Create new service principal - _logger.LogInformation("Creating service principal for app {AppId}", appId); + _logger.LogDebug("Creating service principal for app {AppId}", appId); var createSpUrl = $"{GraphApiConstants.BaseUrl}/v1.0/servicePrincipals"; var createBody = new { @@ -427,7 +425,7 @@ private async Task EnsureScopeOnGrantAsync( // Check if scope already exists if (existingScopes.Contains(scopeToAdd)) { - _logger.LogInformation(" Scope '{Scope}' already exists on grant {GrantId}", scopeToAdd, grantId); + _logger.LogDebug(" Scope '{Scope}' already exists on grant {GrantId}", scopeToAdd, grantId); return true; } @@ -435,7 +433,7 @@ private async Task EnsureScopeOnGrantAsync( existingScopes.Add(scopeToAdd); var newScope = string.Join(' ', existingScopes.OrderBy(s => s)); - _logger.LogInformation(" Updating grant {GrantId} to include scope: {Scope}", grantId, scopeToAdd); + _logger.LogDebug(" Updating grant {GrantId} to include scope: {Scope}", grantId, scopeToAdd); // Update the grant var updateUrl = $"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants/{grantId}"; @@ -462,7 +460,7 @@ private async Task EnsureScopeOnGrantAsync( return true; } - _logger.LogInformation(" Grant updated successfully"); + _logger.LogDebug(" Grant updated successfully"); return true; } catch (Exception ex) @@ -513,7 +511,7 @@ private async Task CreateGrantAsync( using var responseDoc = JsonDocument.Parse(responseJson); var grantId = responseDoc.RootElement.GetProperty("id").GetString(); - _logger.LogInformation(" Permission grant created successfully (ID: {GrantId})", grantId); + _logger.LogDebug(" Permission grant created successfully (ID: {GrantId})", grantId); return true; } catch (Exception ex) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs index a1ee94f9..6129ad78 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs @@ -301,7 +301,7 @@ private async Task OfferPublishInspectionAsync(string publishPath, string zipPat Console.Write("Proceed with deployment? [Y/n]: "); var response = Console.ReadLine()?.Trim().ToLowerInvariant(); - if (response == "n" || response == "no") + if (response is null || response == "n" || response == "no") { _logger.LogInformation("Deployment cancelled by user"); Environment.Exit(0); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs index d4c84ed8..e2cb4410 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs @@ -53,7 +53,7 @@ public async Task> GetFederatedCredentialsAsync( tenantId, $"/beta/applications/{blueprintObjectId}/federatedIdentityCredentials", cancellationToken, - scopes: [AuthenticationConstants.ApplicationReadWriteAllScope]); + scopes: [AuthenticationConstants.AgentIdentityBlueprintAddRemoveCredsAllScope]); JsonDocument? doc; if (primaryDoc != null && primaryDoc.RootElement.TryGetProperty("value", out var valueCheck) && valueCheck.GetArrayLength() > 0) @@ -69,7 +69,7 @@ public async Task> GetFederatedCredentialsAsync( tenantId, $"/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/federatedIdentityCredentials", cancellationToken, - scopes: [AuthenticationConstants.ApplicationReadWriteAllScope]); + scopes: [AuthenticationConstants.AgentIdentityBlueprintAddRemoveCredsAllScope]); } if (doc == null) @@ -267,7 +267,7 @@ public async Task CreateFederatedCredentialAsyn endpoint, payload, cancellationToken, - scopes: [AuthenticationConstants.ApplicationReadWriteAllScope]); + scopes: [AuthenticationConstants.AgentIdentityBlueprintAddRemoveCredsAllScope]); if (response.IsSuccess) { @@ -401,10 +401,7 @@ public async Task DeleteFederatedCredentialAsync( _logger.LogDebug("Deleting federated credential: {CredentialId} from blueprint: {ObjectId}", credentialId, blueprintObjectId); - // Application.ReadWrite.All is the currently functional scope for FIC deletion. - // AddRemoveCreds.All is specified in the permissions reference but is not yet validated; - // restoring Application.ReadWrite.All to match the previously working state. - var ficScope = AuthenticationConstants.ApplicationReadWriteAllScope; + var ficScope = AuthenticationConstants.AgentIdentityBlueprintAddRemoveCredsAllScope; // Try the standard endpoint first var endpoint = $"/beta/applications/{blueprintObjectId}/federatedIdentityCredentials/{credentialId}"; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index ae887dd6..df437818 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -40,6 +40,15 @@ public class GraphApiService // Injectable via constructor so unit tests can bypass the real az process. private readonly Func> _loginHintResolver; + // Delay before retrying a 403 from the agent registry (role propagation lag). + // Injectable so unit tests can pass TimeSpan.Zero and avoid the real 30s wait. + private readonly TimeSpan _agentRegistryRetryDelay; + + // Graph path for the copilot agent registrations endpoint. + // Both RegisterAgentInstanceAsyncV2 and DeleteAgentRegistrationAsync use this path. + // TODO: change from stagingbeta to beta before merging to main. + private const string AgentRegistrationsPath = "/stagingbeta/copilot/agentRegistrations"; + /// /// Optional custom client app ID to use for authentication with Microsoft Graph PowerShell. /// When set, this will be passed to Connect-MgGraph -ClientId parameter. @@ -70,7 +79,7 @@ public record GraphResponse // Allow injecting a custom HttpMessageHandler for unit testing. // loginHintResolver: optional override for login-hint resolution. // Pass () => Task.FromResult(null) in unit tests to skip login-hint resolution. - public GraphApiService(ILogger logger, CommandExecutor executor, IAuthenticationService authService, HttpMessageHandler? handler = null, IMicrosoftGraphTokenProvider? tokenProvider = null, Func>? loginHintResolver = null, string? graphBaseUrl = null, RetryHelper? retryHelper = null) + public GraphApiService(ILogger logger, CommandExecutor executor, IAuthenticationService authService, HttpMessageHandler? handler = null, IMicrosoftGraphTokenProvider? tokenProvider = null, Func>? loginHintResolver = null, string? graphBaseUrl = null, RetryHelper? retryHelper = null, TimeSpan? agentRegistryRetryDelay = null) { _logger = logger; _executor = executor; @@ -81,6 +90,7 @@ public GraphApiService(ILogger logger, CommandExecutor executor // Default: try az CLI first (if present), fall back to JWT cache in AuthenticationService. _loginHintResolver = loginHintResolver ?? (() => ResolveLoginHintWithFallbackAsync(authService)); _graphBaseUrl = string.IsNullOrWhiteSpace(graphBaseUrl) ? GraphApiConstants.BaseUrl : graphBaseUrl; + _agentRegistryRetryDelay = agentRegistryRetryDelay ?? TimeSpan.FromSeconds(30); } // Parameterless constructor to ease test mocking/substitution frameworks which may @@ -212,6 +222,28 @@ private async Task EnsureGraphHeadersAsync(string tenantId, bool forceRefr return doc.RootElement.TryGetProperty("id", out var idEl) ? idEl.GetString() : null; } + /// + /// Returns the set of delegated scope value names (e.g. "Agent365.Observability.OtelWrite") + /// that are published by the service principal's resource app manifest. + /// Used to filter permission grant calls to only include scopes that exist in the tenant. + /// Returns an empty set if the call fails or the SP exposes no delegated scopes. + /// + public virtual async Task> GetAvailableScopeNamesAsync( + string tenantId, string spObjectId, CancellationToken ct = default) + { + using var doc = await GraphGetAsync(tenantId, $"/v1.0/servicePrincipals/{spObjectId}?$select=oauth2PermissionScopes", ct); + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + if (doc?.RootElement.TryGetProperty("oauth2PermissionScopes", out var arr) == true) + { + foreach (var scope in arr.EnumerateArray()) + { + if (scope.TryGetProperty("value", out var val) && val.GetString() is string name) + result.Add(name); + } + } + return result; + } + /// /// Checks whether a service principal with the given object ID exists in the tenant. /// Replaces 'az ad sp show --id {principalId}' (~30s) with a Graph HTTP call (~200ms). @@ -302,7 +334,7 @@ public virtual async Task GraphGetWithResponseAsync(string tenant } } - public virtual async Task GraphPostAsync(string tenantId, string relativePath, object payload, CancellationToken ct = default, IEnumerable? scopes = null) + public virtual async Task GraphPostAsync(string tenantId, string relativePath, object payload, CancellationToken ct = default, IEnumerable? scopes = null, bool logWarningOnFailure = true) { if (!await EnsureGraphHeadersAsync(tenantId, scopes: scopes, ct: ct)) return null; var url = GraphApiConstants.BuildUrl(_graphBaseUrl, relativePath); @@ -314,10 +346,20 @@ public virtual async Task GraphGetWithResponseAsync(string tenant if (!resp.IsSuccessStatusCode) { var errorMessage = TryExtractGraphErrorMessage(body); - if (errorMessage != null) - _logger.LogWarning("Graph POST {Url} failed: {ErrorMessage}", url, errorMessage); + if (logWarningOnFailure) + { + if (errorMessage != null) + _logger.LogWarning("Graph POST {Url} failed: {ErrorMessage}", url, errorMessage); + else + _logger.LogWarning("Graph POST {Url} failed {Code} {Reason}", url, (int)resp.StatusCode, resp.ReasonPhrase); + } else - _logger.LogWarning("Graph POST {Url} failed {Code} {Reason}", url, (int)resp.StatusCode, resp.ReasonPhrase); + { + if (errorMessage != null) + _logger.LogDebug("Graph POST {Url} failed: {ErrorMessage}", url, errorMessage); + else + _logger.LogDebug("Graph POST {Url} failed {Code} {Reason}", url, (int)resp.StatusCode, resp.ReasonPhrase); + } _logger.LogDebug("Graph POST response body: {Body}", body); return null; } @@ -438,11 +480,10 @@ public async Task GraphDeleteAsync( { var body = await resp.Content.ReadAsStringAsync(ct); var errorMessage = TryExtractGraphErrorMessage(body); - if (errorMessage != null) - _logger.LogError("Graph DELETE {Url} failed: {ErrorMessage}", url, errorMessage); + if (!string.IsNullOrWhiteSpace(errorMessage)) + _logger.LogError("Graph DELETE {Url} failed {Code}: {ErrorMessage}", url, (int)resp.StatusCode, errorMessage); else - _logger.LogError("Graph DELETE {Url} failed {Code} {Reason}", url, (int)resp.StatusCode, resp.ReasonPhrase); - _logger.LogDebug("Graph DELETE response body: {Body}", body); + _logger.LogError("Graph DELETE {Url} failed {Code} {Reason}: {Body}", url, (int)resp.StatusCode, resp.ReasonPhrase, body); return false; } @@ -477,6 +518,25 @@ public async Task GraphDeleteAsync( return value[0].GetProperty("id").GetString(); } + /// + /// Checks whether an Entra application with the given appId exists in the tenant. + /// Uses the default az CLI token — does not require CustomClientAppId to be set. + /// Returns false on any error so callers can fall back gracefully. + /// Virtual to allow mocking in unit tests. + /// + public virtual async Task ApplicationExistsByAppIdAsync( + string tenantId, string appId, CancellationToken ct = default) + { + if (!Guid.TryParse(appId, out var validGuid)) return false; + + using var doc = await GraphGetAsync( + tenantId, + $"/v1.0/applications?$filter=appId eq '{validGuid:D}'&$select=appId&$top=1", + ct); + if (doc == null) return false; + return doc.RootElement.TryGetProperty("value", out var value) && value.GetArrayLength() > 0; + } + /// /// Looks up the display name of a service principal by its application ID. /// Returns null if the service principal is not found. @@ -500,24 +560,72 @@ public async Task GraphDeleteAsync( return displayName.GetString(); } + /// + /// Finds an application's appId by its display name using a Graph advanced query. + /// Uses ConsistencyLevel: eventual (required for string filter on displayName). + /// Returns null if not found or on error. Does not require CustomClientAppId — uses the + /// default auth token path so it can be called before the client app is resolved. + /// + public virtual async Task FindApplicationByDisplayNameAsync( + string tenantId, string displayName, CancellationToken ct = default) + { + if (!await EnsureGraphHeadersAsync(tenantId, ct: ct)) return null; + + // OData requires single quotes to be escaped by doubling them: ' → '' + var escaped = displayName.Replace("'", "''", StringComparison.Ordinal); + var url = GraphApiConstants.BuildUrl(_graphBaseUrl, + $"/v1.0/applications?$filter=displayName eq '{escaped}'&$select=appId&$top=1&$count=true"); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + // Copy auth header set by EnsureGraphHeadersAsync onto the shared _httpClient + if (_httpClient.DefaultRequestHeaders.Authorization is { } auth) + request.Headers.Authorization = auth; + // Required for advanced query filters (displayName eq) + request.Headers.TryAddWithoutValidation("ConsistencyLevel", "eventual"); + + using var resp = await _httpClient.SendAsync(request, ct); + if (!resp.IsSuccessStatusCode) + { + _logger.LogDebug("FindApplicationByDisplayName {Name} failed {Code}", displayName, (int)resp.StatusCode); + return null; + } + + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + if (!doc.RootElement.TryGetProperty("value", out var value) || value.GetArrayLength() == 0) + return null; + + return value[0].TryGetProperty("appId", out var appId) ? appId.GetString() : null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug(ex, "Failed to find application by display name {Name}", displayName); + return null; + } + } + /// /// Ensures a service principal exists for the given application ID. /// Creates the service principal if it doesn't already exist. + /// Returns null if the SP could not be found or created (e.g. insufficient privileges). /// Virtual to allow mocking in unit tests using Moq. /// - public virtual async Task EnsureServicePrincipalForAppIdAsync( - string tenantId, string appId, CancellationToken ct = default, IEnumerable? scopes = null) + public virtual async Task EnsureServicePrincipalForAppIdAsync( + string tenantId, string appId, CancellationToken ct = default, IEnumerable? scopes = null, + bool logWarningOnCreateFailure = true) { // Try existing var spId = await LookupServicePrincipalByAppIdAsync(tenantId, appId, ct, scopes); - if (!string.IsNullOrWhiteSpace(spId)) return spId!; + if (!string.IsNullOrWhiteSpace(spId)) return spId; - // Create SP for this application - var created = await GraphPostAsync(tenantId, "/v1.0/servicePrincipals", new { appId }, ct, scopes); + // Create SP for this application (suppresses warning log when logWarningOnCreateFailure is false) + var created = await GraphPostAsync(tenantId, "/v1.0/servicePrincipals", new { appId }, ct, scopes, + logWarningOnFailure: logWarningOnCreateFailure); if (created == null || !created.RootElement.TryGetProperty("id", out var idProp)) - throw new InvalidOperationException($"Failed to create servicePrincipal for appId {appId}"); + return null; - return idProp.GetString()!; + return idProp.GetString(); } public async Task CreateOrUpdateOauth2PermissionGrantAsync( @@ -598,13 +706,13 @@ private async Task CreateOrUpdateOauth2PermissionGrantCoreAsync( string? existingId = null; string existingScopes = ""; - var filter = isPrincipal - ? $"clientId eq '{clientSpObjectId}'" + var existingFilter = principalId is not null + ? $"clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}' and consentType eq 'Principal' and principalId eq '{principalId}'" : $"clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}'"; using (var listDoc = await GraphGetAsync( tenantId, - $"/v1.0/oauth2PermissionGrants?$filter={filter}", + $"/v1.0/oauth2PermissionGrants?$filter={existingFilter}", ct, permissionGrantScopes)) { @@ -641,10 +749,11 @@ private async Task CreateOrUpdateOauth2PermissionGrantCoreAsync( if (string.IsNullOrWhiteSpace(existingId)) { - // Build payload — Principal grants include principalId. - object payload = isPrincipal - ? new { clientId = clientSpObjectId, consentType, principalId, resourceId = resourceSpObjectId, scope = desiredScopeString } - : new { clientId = clientSpObjectId, consentType, resourceId = resourceSpObjectId, scope = desiredScopeString }; + // Principal grants can be created by the developer for their own account. + // AllPrincipals (tenant-wide) grants require Global Administrator. + object payload = principalId is not null + ? new { clientId = clientSpObjectId, consentType = "Principal", principalId, resourceId = resourceSpObjectId, scope = desiredScopeString } + : new { clientId = clientSpObjectId, consentType = "AllPrincipals", resourceId = resourceSpObjectId, scope = desiredScopeString }; _logger.LogDebug("Graph POST /v1.0/oauth2PermissionGrants ({ConsentType}) body: {Body}", consentType, JsonSerializer.Serialize(payload)); @@ -663,7 +772,12 @@ private async Task CreateOrUpdateOauth2PermissionGrantCoreAsync( return true; if (!grantResponse.Body.Contains("Directory_ObjectNotFound", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "OAuth2 permission grant failed (non-transient) for resource {ResourceSpId} with scopes [{Scopes}]. Graph response: {Body}", + resourceSpObjectId, desiredScopeString, grantResponse.Body); return false; // non-transient error, do not retry + } if (attempt < maxRetries - 1) { @@ -767,7 +881,7 @@ private async Task CreateOrUpdateOauth2PermissionGrantCoreAsync( } var json = await response.Content.ReadAsStringAsync(ct); - var doc = JsonDocument.Parse(json); + using var doc = JsonDocument.Parse(json); var roles = new List(); if (doc.RootElement.TryGetProperty("value", out var rolesArray)) @@ -986,6 +1100,632 @@ public virtual async Task IsApplicationOwnerAsync( return _loginHint; } + /// + /// Registers an agent instance in the Microsoft Agent Registry via + /// POST /beta/agentRegistry/agentInstances. + /// Requires the caller to hold the "Agent Registry Administrator" Entra role + /// and have consented to the AgentInstance.ReadWrite.All delegated scope. + /// Returns the new agent instance ID, or null on failure. + /// + public virtual async Task RegisterAgentInstanceAsync( + string tenantId, + string displayName, + string? agentBlueprintId, + CancellationToken ct = default) + { + // Resolve the current user's object ID so we can populate ownerIds (required field). + using var meDoc = await GraphGetAsync(tenantId, "/v1.0/me?$select=id", ct); + if (meDoc == null) + { + _logger.LogError("Failed to retrieve current user ID from Microsoft Graph."); + return null; + } + + if (!meDoc.RootElement.TryGetProperty("id", out var userIdProp)) + { + _logger.LogError("Current user ID not found in Graph /me response."); + return null; + } + + var currentUserId = userIdProp.GetString(); + + var payload = new Dictionary + { + ["ownerIds"] = new[] { currentUserId }, + ["displayName"] = displayName + }; + + if (!string.IsNullOrWhiteSpace(agentBlueprintId)) + payload["agentIdentityBlueprintId"] = agentBlueprintId; + + _logger.LogDebug("POST /beta/agentRegistry/agentInstances: ownerIds=[{UserId}], displayName={DisplayName}, agentIdentityBlueprintId={BlueprintId}", + currentUserId, displayName, agentBlueprintId ?? "(none)"); + + // AgentInstance.ReadWrite.All is a user-delegated scope (no admin consent required). + // We must request it explicitly so EnsureGraphHeadersAsync uses the MSAL path with the + // custom client app — that app already has AgentInstance.ReadWrite.All consented via + // RequiredClientAppPermissions. Using the az CLI token (no scope) would require the + // scope to be consented on the Azure CLI app instead, which is not the expected setup. + IEnumerable? registrationScopes = _tokenProvider != null + ? [Constants.AuthenticationConstants.AgentInstanceReadWriteAllScope] + : null; + + var firstResponse = await GraphPostWithResponseAsync(tenantId, "/beta/agentRegistry/agentInstances", payload, ct, registrationScopes); + + if (firstResponse.IsSuccess) + { + var instanceId = ExtractAgentInstanceId(firstResponse); + firstResponse.Json?.Dispose(); + if (instanceId == null) + _logger.LogError("Agent instance created but response did not contain an 'id' field."); + return instanceId; + } + + var firstStatusCode = firstResponse.StatusCode; + var firstBody = firstResponse.Body; + firstResponse.Json?.Dispose(); + + // On auth failure (0 = token acquisition failed): no point retrying. + if (firstStatusCode == 0) + { + _logger.LogError("Failed to acquire an access token for the agent registry request. Ensure 'az login' is completed."); + return null; + } + + // On non-403: log the status and body so the caller has something to act on. + if (firstStatusCode != 403) + { + _logger.LogError("Agent registry POST failed with HTTP {StatusCode}. Body: {Body}", firstStatusCode, firstBody); + return null; + } + + // On 403: the 'Agent Registry Administrator' role may not have propagated yet. + // Wait 30s before retrying — an immediate retry always returns another 403. + _logger.LogInformation("403 from agent registry — 'Agent Registry Administrator' role may not have propagated yet. Waiting {Delay}s before retry...", (int)_agentRegistryRetryDelay.TotalSeconds); + await Task.Delay(_agentRegistryRetryDelay, ct); + + var retryResponse = await GraphPostWithResponseAsync(tenantId, "/beta/agentRegistry/agentInstances", payload, ct, registrationScopes); + + if (retryResponse.IsSuccess) + { + _logger.LogInformation("Agent instance registration succeeded on retry."); + var instanceId = ExtractAgentInstanceId(retryResponse); + retryResponse.Json?.Dispose(); + if (instanceId == null) + _logger.LogError("Agent instance created but retry response did not contain an 'id' field."); + return instanceId; + } + + var retryStatusCode = retryResponse.StatusCode; + retryResponse.Json?.Dispose(); + + if (retryStatusCode == 403) + { + _logger.LogError( + "Still 403 after retry. Ensure the 'Agent Registry Administrator' role is " + + "assigned in Entra ID for the account running the CLI. " + + "If the role was recently assigned, wait 5-15 minutes for propagation and retry."); + } + else if (retryStatusCode == 0) + { + _logger.LogError("Token re-acquisition failed on retry. Ensure 'az login' is still valid."); + } + else + { + _logger.LogError("Agent registry POST failed on retry with HTTP {StatusCode}.", retryStatusCode); + } + + return null; + } + + /// + /// Registers an agent instance via the Microsoft Graph copilot/agentRegistrations endpoint + /// (POST ). + /// Acquires a delegated Graph token via the custom app token provider (.default scope) so the + /// token includes AgentRegistration.ReadWrite.All, or falls back to the az CLI Graph token. + /// Returns the new agent registration ID on success (200 OK), or null on failure. + /// + public virtual async Task RegisterAgentInstanceAsyncV2( + string tenantId, + string displayName, + string? description, + string? blueprintId, + string? agentIdentityId, + string? clientAppId, + CancellationToken ct = default) + { + // Resolve current user ID from Graph (needed for ownerIds and createdBy). + var currentUserId = await GetCurrentUserObjectIdAsync(tenantId, ct); + if (string.IsNullOrWhiteSpace(currentUserId)) + { + _logger.LogError("Failed to retrieve current user ID — required for agent registration."); + return null; + } + + // Use the custom app token provider with .default so the token is issued to the "Agent 365 CLI" + // app (7277bd3e-...) which has AgentRegistration.ReadWrite.All consented. Requesting the scope + // by name causes AADSTS650053 with the az CLI public client; .default includes all consented + // permissions for the resource without enumerating them. + IEnumerable? registrationScopes = _tokenProvider != null + ? [$"{Constants.AuthenticationConstants.MicrosoftGraphResourceUri}/.default"] + : null; + + var now = DateTimeOffset.UtcNow.ToString("o"); + var payload = new Dictionary + { + ["id"] = Guid.NewGuid().ToString(), + ["displayName"] = displayName, + ["ownerIds"] = new[] { currentUserId }, + ["createdBy"] = currentUserId, + ["sourceCreatedDateTime"] = now, + ["sourceLastModifiedDateTime"] = now, + }; + + if (!string.IsNullOrWhiteSpace(description)) + payload["description"] = description; + if (!string.IsNullOrWhiteSpace(blueprintId)) + payload["agentIdentityBlueprintId"] = blueprintId; + // sourceAgentId is required by the contract. Use agentIdentityId when available, + // fall back to blueprintId as the stable external identifier. + payload["sourceAgentId"] = !string.IsNullOrWhiteSpace(agentIdentityId) ? agentIdentityId : blueprintId ?? string.Empty; + if (!string.IsNullOrWhiteSpace(agentIdentityId)) + payload["agentIdentityId"] = agentIdentityId; + // managedByAppId must be the AgentX service app ID, not the CLI client app ID. + // Using the CLI client app ID causes 424 "You do not have permission to create + // an agent registration managed by another AppId." + payload["managedByAppId"] = Constants.AuthenticationConstants.AgentXAppId; + + _logger.LogDebug("POST {Url}", AgentRegistrationsPath); + _logger.LogDebug("Body: {Body}", JsonSerializer.Serialize(payload)); + + var response = await _retryHelper.ExecuteWithRetryAsync( + token => GraphPostWithResponseAsync(tenantId, AgentRegistrationsPath, payload, token, registrationScopes), + r => + { + if (r.StatusCode is not (502 or 503 or 504)) return false; + _logger.LogWarning( + "Agent registration request returned HTTP {StatusCode} (transient); retrying...", + r.StatusCode); + r.Json?.Dispose(); + return true; + }, + maxRetries: 3, + baseDelaySeconds: 2, + cancellationToken: ct); + + // Log token claims so scope/audience issues are visible in -v output. + var registrationToken = _httpClient.DefaultRequestHeaders.Authorization?.Parameter; + if (!string.IsNullOrWhiteSpace(registrationToken)) + LogJwtClaims(registrationToken, "agent registration token"); + + if (response.IsSuccess) + { + _logger.LogDebug("Agent registration response body: {Body}", response.Body); + + string? registrationId = null; + if (response.Json != null && response.Json.RootElement.TryGetProperty("id", out var idProp)) + registrationId = idProp.GetString(); + registrationId ??= payload["id"]?.ToString(); + + response.Json?.Dispose(); + return registrationId; + } + + if (response.StatusCode == 403) + _logger.LogError( + "Agent registration failed (403 Forbidden). " + + "Ensure the signed-in user has the required Entra role (e.g., Agent Registry Administrator) " + + "and the tenant is enrolled in the required preview program. Response: {Body}", response.Body); + else + _logger.LogError("Agent registration failed with HTTP {StatusCode}. Body: {Body}", response.StatusCode, response.Body); + response.Json?.Dispose(); + return null; + } + + /// + /// Deletes an agent registration via the Microsoft Graph copilot/agentRegistrations endpoint + /// (DELETE /{id}). + /// Returns true on success or if the registration was already deleted (404). + /// + public virtual async Task DeleteAgentRegistrationAsync( + string tenantId, + string registrationId, + CancellationToken ct = default) + { + // Use the custom app token provider with .default so the token includes AgentRegistration.ReadWrite.All + // (consented on the "Agent 365 CLI" app). .default avoids AADSTS650053 from explicit scope names. + IEnumerable? scopes = _tokenProvider != null + ? [$"{Constants.AuthenticationConstants.MicrosoftGraphResourceUri}/.default"] + : null; + + _logger.LogInformation("DELETE https://graph.microsoft.com{Path}/{RegistrationId}", AgentRegistrationsPath, registrationId); + + return await GraphDeleteAsync( + tenantId, + $"{AgentRegistrationsPath}/{registrationId}", + ct, + treatNotFoundAsSuccess: true, + scopes: scopes); + } + + /// + /// Deletes an agent instance from the Microsoft Agent Registry via + /// DELETE /beta/agentRegistry/agentInstances/{instanceId}. + /// Requires AgentInstance.ReadWrite.All delegated scope. + /// Returns true on success or if the instance was already deleted (404). + /// + public virtual async Task DeleteAgentInstanceAsync( + string tenantId, + string instanceId, + CancellationToken ct = default) + { + IEnumerable? scopes = _tokenProvider != null + ? [Constants.AuthenticationConstants.AgentInstanceReadWriteAllScope] + : null; + + _logger.LogInformation("DELETE https://graph.microsoft.com/beta/agentRegistry/agentInstances/{InstanceId}", instanceId); + + return await GraphDeleteAsync( + tenantId, + $"/beta/agentRegistry/agentInstances/{instanceId}", + ct, + treatNotFoundAsSuccess: true, + scopes: scopes); + } + + /// + /// Acquires an access token for the blueprint application using the OAuth 2.0 client credentials flow. + /// Used by to authenticate as the blueprint application itself. + /// + public virtual async Task GetBlueprintAccessTokenAsync( + string tenantId, + string clientId, + string clientSecret, + CancellationToken ct, + string? correlationId = null) + { + var effectiveCorrelationId = string.IsNullOrWhiteSpace(correlationId) + ? HttpClientFactory.GenerateCorrelationId() + : correlationId; + + try + { + _logger.LogDebug("Acquiring blueprint access token via client credentials (CorrelationId: {Id})", effectiveCorrelationId); + + using var httpClient = HttpClientFactory.CreateAuthenticatedClient(correlationId: effectiveCorrelationId); + var tokenEndpoint = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; + + const int maxRetries = 5; + const int baseDelaySeconds = 5; + + for (int attempt = 0; attempt < maxRetries; attempt++) + { + // FormUrlEncodedContent is a one-shot stream — must be recreated per attempt. + using var requestBody = new FormUrlEncodedContent(new[] + { + new KeyValuePair("client_id", clientId), + new KeyValuePair("client_secret", clientSecret), + new KeyValuePair("scope", "https://graph.microsoft.com/.default"), + new KeyValuePair("grant_type", "client_credentials"), + }); + + using var response = await httpClient.PostAsync(tokenEndpoint, requestBody, ct); + + if (response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(ct); + using var tokenDoc = JsonDocument.Parse(responseContent); + return tokenDoc.RootElement.GetProperty("access_token").GetString(); + } + + var errorContent = await response.Content.ReadAsStringAsync(ct); + + // AADSTS7000215 means the credential exists in AAD but is not yet visible on this + // replica — same eventual consistency window as object replication. Retry with backoff. + var isCredentialPropagationLag = response.StatusCode == System.Net.HttpStatusCode.Unauthorized + && errorContent.Contains("AADSTS7000215", StringComparison.OrdinalIgnoreCase); + + if (!isCredentialPropagationLag || attempt == maxRetries - 1) + { + _logger.LogError("Failed to acquire blueprint access token: {Status} - {Error}", + response.StatusCode, errorContent); + if (errorContent.Contains("invalid_client", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError("Invalid client credentials — verify the blueprint client secret in a365.generated.config.json is correct and not expired."); + } + return null; + } + + var delaySecs = Math.Min(baseDelaySeconds * (int)Math.Pow(2, attempt), 60); + _logger.LogInformation( + "Blueprint credential not yet propagated (AADSTS7000215) — retrying in {Delay}s (attempt {Attempt} of {Max})...", + delaySecs, attempt + 1, maxRetries - 1); + await Task.Delay(TimeSpan.FromSeconds(delaySecs), ct); + } + + return null; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception acquiring blueprint access token: {Message}", ex.Message); + return null; + } + } + + /// + /// Creates an Agent Identity in the tenant using the delegated flow. + /// Authenticates as the calling user with AgentIdentity.Create.All scope and calls + /// POST /beta/servicePrincipals/Microsoft.Graph.AgentIdentity with agentIdentityBlueprintId. + /// Requires Agent ID Administrator, Agent ID Developer, or Global Administrator role. + /// No blueprint client secret required — preferred over the client-credentials path when possible. + /// + /// The agent identity ID on success, null on failure. + public virtual async Task CreateAgentIdentityDelegatedAsync( + string tenantId, + string blueprintId, + string displayName, + CancellationToken ct) + { + var correlationId = HttpClientFactory.GenerateCorrelationId(); + _logger.LogDebug("Creating agent identity via delegated flow (CorrelationId: {Id})", correlationId); + + string? currentUserId = null; + try + { + currentUserId = await GetCurrentUserObjectIdAsync(tenantId, ct); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not resolve current user ID (non-fatal): {Message}", ex.Message); + } + + var scopes = new[] { Constants.AuthenticationConstants.AgentIdentityCreateAllScope }; + + // Log the token claims once here so it's easy to correlate with the 403 if it fails. + if (_tokenProvider != null) + { + try + { + var loginHint = await ResolveLoginHintAsync(); + var previewToken = await _tokenProvider.GetMgGraphAccessTokenAsync( + tenantId, scopes, false, CustomClientAppId, ct, loginHint); + if (!string.IsNullOrWhiteSpace(previewToken)) + { + var scp = TryDecodeTokenClaim(previewToken, "scp"); + var upn = TryDecodeTokenClaim(previewToken, "upn") ?? TryDecodeTokenClaim(previewToken, "unique_name"); + _logger.LogDebug("Agent identity token scp : {Scp}", scp ?? "(missing)"); + _logger.LogDebug("Agent identity token upn : {Upn}", upn ?? "(missing)"); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug(ex, "Could not preview token claims (non-fatal)"); + } + } + + try + { + var body = new JsonObject + { + ["displayName"] = displayName, + ["agentIdentityBlueprintId"] = blueprintId, + }; + + if (!string.IsNullOrWhiteSpace(currentUserId)) + { + body["sponsors@odata.bind"] = new JsonArray + { + $"https://graph.microsoft.com/v1.0/users/{currentUserId}" + }; + body["owners@odata.bind"] = new JsonArray + { + $"https://graph.microsoft.com/v1.0/users/{currentUserId}" + }; + } + + _logger.LogDebug("POST https://graph.microsoft.com/beta/servicePrincipals/Microsoft.Graph.AgentIdentity (delegated)"); + _logger.LogDebug("Body: {Body}", body.ToJsonString()); + + // Use GraphPostWithResponseAsync so we can log the full error body on failure. + var postResult = await GraphPostWithResponseAsync( + tenantId, + "/beta/servicePrincipals/Microsoft.Graph.AgentIdentity", + body, + ct, + scopes: scopes); + + if (!postResult.IsSuccess) + { + _logger.LogWarning("Graph POST /beta/servicePrincipals/Microsoft.Graph.AgentIdentity failed: HTTP {Status} {Reason}", + postResult.StatusCode, postResult.ReasonPhrase); + _logger.LogInformation("Error response body: {Body}", postResult.Body); + postResult.Json?.Dispose(); + return null; + } + + if (postResult.Json == null) + { + _logger.LogDebug("Delegated agent identity creation returned null — will fall back to client credentials if available."); + return null; + } + + using var doc = postResult.Json; + + var id = doc.RootElement.GetProperty("id").GetString(); + _logger.LogDebug("Agent identity created via delegated flow (ID: {Id})", id); + return id; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Delegated agent identity creation failed: {Message}", ex.Message); + return null; + } + } + + /// + /// Creates an Agent Identity in the tenant by instantiating the blueprint. + /// Authenticates as the blueprint application (client credentials), then calls + /// POST /beta/serviceprincipals/Microsoft.Graph.AgentIdentity. + /// Saves the returned identity ID as AgenticAppId in the config. + /// + /// The agent identity ID on success, null on failure. + public virtual async Task CreateAgentIdentityAsync( + string tenantId, + string blueprintId, + string blueprintClientSecret, + string displayName, + CancellationToken ct) + { + var correlationId = HttpClientFactory.GenerateCorrelationId(); + + if (string.IsNullOrWhiteSpace(blueprintClientSecret)) + { + _logger.LogError("Blueprint client secret is required to create agent identity. " + + "Ensure blueprint setup completed successfully."); + return null; + } + + var appToken = await GetBlueprintAccessTokenAsync( + tenantId, blueprintId, blueprintClientSecret, ct, correlationId); + + if (string.IsNullOrWhiteSpace(appToken)) + { + _logger.LogError("Failed to acquire blueprint access token for agent identity creation."); + return null; + } + + // Optionally include the current user as sponsor. + string? currentUserId = null; + try + { + currentUserId = await GetCurrentUserObjectIdAsync(tenantId, ct); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not resolve current user ID for sponsor (non-fatal): {Message}", ex.Message); + } + + try + { + _logger.LogDebug("Creating agent identity (CorrelationId: {Id})", correlationId); + + using var httpClient = HttpClientFactory.CreateAuthenticatedClient(appToken, correlationId: correlationId); + + var body = new JsonObject + { + ["displayName"] = displayName, + ["agentAppId"] = blueprintId, + }; + + if (!string.IsNullOrWhiteSpace(currentUserId)) + { + body["sponsors@odata.bind"] = new JsonArray + { + $"https://graph.microsoft.com/v1.0/users/{currentUserId}" + }; + } + + using var content = new StringContent( + body.ToJsonString(), + System.Text.Encoding.UTF8, + "application/json"); + + using var response = await httpClient.PostAsync( + "https://graph.microsoft.com/beta/serviceprincipals/Microsoft.Graph.AgentIdentity", + content, + ct); + + // Some tenants reject sponsor binding — retry without it. + if (!response.IsSuccessStatusCode && response.StatusCode == System.Net.HttpStatusCode.BadRequest + && !string.IsNullOrWhiteSpace(currentUserId)) + { + _logger.LogDebug("Agent identity creation with sponsor failed (400); retrying without sponsor."); + body.Remove("sponsors@odata.bind"); + using var content2 = new StringContent(body.ToJsonString(), System.Text.Encoding.UTF8, "application/json"); + using var response2 = await httpClient.PostAsync( + "https://graph.microsoft.com/beta/serviceprincipals/Microsoft.Graph.AgentIdentity", + content2, + ct); + + if (!response2.IsSuccessStatusCode) + { + var err = await response2.Content.ReadAsStringAsync(ct); + _logger.LogError("Failed to create agent identity: {Status} - {Error}", response2.StatusCode, err); + return null; + } + + var json2 = await response2.Content.ReadAsStringAsync(ct); + using var doc2 = JsonDocument.Parse(json2); + var id2 = doc2.RootElement.GetProperty("id").GetString(); + _logger.LogInformation("Agent identity created (ID: {Id})", id2); + return id2; + } + + if (!response.IsSuccessStatusCode) + { + var err = await response.Content.ReadAsStringAsync(ct); + _logger.LogError("Failed to create agent identity: {Status} - {Error}", response.StatusCode, err); + if (err.Contains("Authorization_RequestDenied", StringComparison.OrdinalIgnoreCase) || + err.Contains("calling identity type", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError("Authorization denied. Ensure the blueprint application has " + + "Application.ReadWrite.All and AgentIdentity.Create.OwnedBy application permissions."); + } + return null; + } + + var json = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(json); + var id = doc.RootElement.GetProperty("id").GetString(); + _logger.LogInformation("Agent identity created (ID: {Id})", id); + return id; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create agent identity: {Message}", ex.Message); + return null; + } + } + + private static string? ExtractAgentInstanceId(GraphResponse response) + { + if (response.Json == null) return null; + if (!response.Json.RootElement.TryGetProperty("id", out var idProp)) + return null; + return idProp.GetString(); + } + + /// + /// Decodes a JWT payload and returns the value of the specified claim. + /// Used for debug logging only — never log the full token. + /// Returns null if the token cannot be decoded or the claim is absent. + /// + private static string? TryDecodeTokenClaim(string token, string claimName) + { + try + { + var parts = token.Split('.'); + if (parts.Length < 2) return null; + var payload = parts[1]; + payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '='); + var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload)); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.TryGetProperty(claimName, out var claim) ? claim.GetString() : null; + } + catch + { + return null; + } + } + /// /// Attempts to extract a human-readable error message from a Graph API JSON error response body. /// Returns null if the body cannot be parsed or does not contain an error message. @@ -1005,4 +1745,43 @@ public virtual async Task IsApplicationOwnerAsync( catch { /* ignore parse errors */ } return null; } + + private void LogJwtClaims(string token, string label) + { + try + { + var parts = token.Split('.'); + if (parts.Length < 2) return; + var payload = parts[1]; + // Pad base64url to standard base64 + payload = payload.Replace('-', '+').Replace('_', '/'); + payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '='); + var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload)); + using var doc = System.Text.Json.JsonDocument.Parse(json); + var root = doc.RootElement; + + string Get(string claim) => + root.TryGetProperty(claim, out var v) ? v.ToString() : "(absent)"; + + string expReadable = "(absent)"; + if (root.TryGetProperty("exp", out var expEl) && expEl.TryGetInt64(out var expEpoch)) + expReadable = DateTimeOffset.FromUnixTimeSeconds(expEpoch).ToString("u"); + + _logger.LogDebug( + "{Label} claims — aud: {Aud} | scp: {Scp} | roles: {Roles} | tid: {Tid} | oid: {Oid} | upn: {Upn} | appid: {AppId} | exp: {Exp}", + label, + Get("aud"), + Get("scp"), + Get("roles"), + Get("tid"), + Get("oid"), + Get("upn"), + Get("appid"), + expReadable); + } + catch (Exception ex) + { + _logger.LogDebug("Could not decode JWT claims for {Label}: {Message}", label, ex.Message); + } + } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs index dc27b8c7..d5ea83d2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs @@ -12,10 +12,14 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; /// Custom console formatter that outputs clean messages without timestamps or category names. /// Follows Azure CLI output patterns for user-friendly CLI experience. /// Errors are displayed in red, warnings in yellow, info is plain text, debug/trace in dark gray. +/// +/// Supports log indent scopes via : +/// each instance on the scope stack adds 4 spaces of leading indent. +/// Maximum indent depth: 3 levels (12 spaces). /// public sealed class CleanConsoleFormatter : ConsoleFormatter { - public CleanConsoleFormatter() + public CleanConsoleFormatter() : base("clean") { } @@ -54,6 +58,16 @@ public override void Write( return; } + // Compute indent prefix from LogIndentScope instances in the scope stack. + // ForEachScope is synchronous — safe to capture a local variable in the callback. + // Each LogIndentScope instance = one indent level (4 spaces); capped at 3. + var indentLevel = 0; + scopeProvider?.ForEachScope( + (scope, _) => { if (scope is LogIndentScope) indentLevel++; }, + (object?)null); + indentLevel = Math.Min(indentLevel, 3); + var indent = indentLevel > 0 ? new string(' ', indentLevel * 4) : string.Empty; + // Azure CLI pattern: red for errors, yellow for warnings, dark gray for debug/trace, no color for info switch (logEntry.LogLevel) { @@ -62,6 +76,7 @@ public override void Write( if (isConsole) { Console.ForegroundColor = ConsoleColor.Red; + Console.Write(indent); Console.Write("ERROR: "); Console.Write(message); Console.ResetColor(); @@ -69,6 +84,7 @@ public override void Write( } else { + textWriter.Write(indent); textWriter.Write("ERROR: "); textWriter.WriteLine(message); } @@ -77,12 +93,14 @@ public override void Write( if (isConsole) { Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write(indent); Console.Write(message); Console.ResetColor(); Console.WriteLine(); } else { + textWriter.Write(indent); textWriter.WriteLine(message); } break; @@ -90,6 +108,7 @@ public override void Write( if (isConsole) { Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write(indent); Console.Write("[DEBUG] "); Console.Write(message); Console.ResetColor(); @@ -97,6 +116,7 @@ public override void Write( } else { + textWriter.Write(indent); textWriter.Write("[DEBUG] "); textWriter.WriteLine(message); } @@ -105,6 +125,7 @@ public override void Write( if (isConsole) { Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write(indent); Console.Write("[TRACE] "); Console.Write(message); Console.ResetColor(); @@ -112,6 +133,7 @@ public override void Write( } else { + textWriter.Write(indent); textWriter.Write("[TRACE] "); textWriter.WriteLine(message); } @@ -120,11 +142,11 @@ public override void Write( if (isConsole) { Console.ResetColor(); - Console.WriteLine(message); + Console.WriteLine(indent + message); } else { - textWriter.WriteLine(message); + textWriter.WriteLine(indent + message); } break; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/LogIndentScope.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/LogIndentScope.cs new file mode 100644 index 00000000..22bdeea7 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/LogIndentScope.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; + +/// +/// Marker scope type for CLI output indentation. +/// Push onto the logging scope stack via . +/// Each instance in the scope stack adds one indent level (4 spaces) to log messages +/// rendered by . +/// +internal sealed class LogIndentScope +{ + // Singleton — contents are irrelevant; CleanConsoleFormatter counts instances in the stack. + public static readonly LogIndentScope Instance = new(); + + private LogIndentScope() { } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/LoggerExtensions.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/LoggerExtensions.cs new file mode 100644 index 00000000..10607030 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/LoggerExtensions.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; + +/// +/// Extension methods for providing CLI output formatting helpers. +/// +internal static class LoggerExtensions +{ + /// + /// Opens a log indent scope. All log messages emitted within the using block are + /// indented by one additional level (4 spaces) when rendered by . + /// Scopes are nestable (up to 3 levels; deeper scopes are clamped). + /// + /// + /// + /// logger.LogInformation("Creating blueprint application..."); + /// using (logger.Indent()) + /// { + /// logger.LogInformation("Display Name: {Name}", name); + /// logger.LogInformation("Blueprint ID: {Id}", id); + /// } + /// + /// + public static IDisposable Indent(this ILogger logger) => + logger.BeginScope(LogIndentScope.Instance) + ?? NullDisposable.Instance; + + private sealed class NullDisposable : IDisposable + { + public static readonly NullDisposable Instance = new(); + public void Dispose() { } + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/RetryHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/RetryHelper.cs index eab9e917..4122f0f9 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/RetryHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/RetryHelper.cs @@ -61,7 +61,7 @@ public async Task ExecuteWithRetryAsync( if (attempt < retries - 1) { var delaySpan = CalculateDelay(attempt, delay); - _logger.LogInformation( + _logger.LogDebug( "Retry attempt {AttemptNumber} of {MaxRetries}. Waiting {DelaySeconds} seconds...", attempt + 1, retries, (int)delaySpan.TotalSeconds); @@ -81,7 +81,7 @@ public async Task ExecuteWithRetryAsync( if (attempt < retries - 1) { var delaySpan = CalculateDelay(attempt, delay); - _logger.LogInformation( + _logger.LogDebug( "Retry attempt {AttemptNumber} of {MaxRetries}. Waiting {DelaySeconds} seconds...", attempt + 1, retries, (int)delaySpan.TotalSeconds); @@ -172,7 +172,7 @@ public async Task ExecuteWithRetryAsync( if (attempt < retries - 1) { var delaySpan = CalculateDelay(attempt, delay); - _logger.LogInformation( + _logger.LogDebug( "Retry attempt {AttemptNumber} of {MaxRetries}. Waiting {DelaySeconds} seconds...", attempt + 1, retries, (int)delaySpan.TotalSeconds); @@ -192,7 +192,7 @@ public async Task ExecuteWithRetryAsync( if (attempt < retries - 1) { var delaySpan = CalculateDelay(attempt, delay); - _logger.LogInformation( + _logger.LogDebug( "Retry attempt {AttemptNumber} of {MaxRetries}. Waiting {DelaySeconds} seconds...", attempt + 1, retries, (int)delaySpan.TotalSeconds); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IClientAppValidator.cs index b218c24c..4cbab5a4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IClientAppValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IClientAppValidator.cs @@ -29,4 +29,15 @@ public interface IClientAppValidator /// The tenant ID /// Cancellation token Task EnsureRedirectUrisAsync(string clientAppId, string tenantId, CancellationToken ct = default); + + /// + /// Returns the subset of required permissions that are not yet present in the client app's + /// oauth2PermissionGrant (i.e. not consented). Used to prompt the user before granting. + /// + Task> GetUnconsentedRequiredPermissionsAsync(string clientAppId, string tenantId, CancellationToken ct = default); + + /// + /// Extends the client app's oauth2PermissionGrant to include the given permissions. + /// + Task GrantConsentForPermissionsAsync(string clientAppId, List permissions, string tenantId, CancellationToken ct = default); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IConfigService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IConfigService.cs index 3020102e..f4009571 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IConfigService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IConfigService.cs @@ -80,6 +80,16 @@ Task CreateDefaultConfigAsync( /// Path where the state file should be created /// Thrown when file write fails Task InitializeStateAsync(string statePath = "a365.generated.config.json"); + + /// + /// Validates the configured clientAppId still exists in the tenant and, if not, resolves + /// it by looking up the well-known display name "Agent 365 CLI". + /// When a new ID is found it is written back to a365.config.json so subsequent LoadAsync + /// calls return the correct value without manual config edits. + /// Safe to call before any command — uses az CLI token, not MSAL. + /// No-ops silently if no config file exists or if the tenant ID is unavailable. + /// + Task TryResolveClientAppIdAsync(GraphApiService graphApiService, CancellationToken ct = default); } /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs index a7dff190..533cd55e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs @@ -24,24 +24,17 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// public sealed class InteractiveGraphAuthService { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly string _clientAppId; private readonly Func? _credentialFactory; private readonly Func> _loginHintResolver; private GraphServiceClient? _cachedClient; private string? _cachedTenantId; - // 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/AgentIdentityBlueprint.ReadWrite.All", - "https://graph.microsoft.com/AgentIdentityBlueprint.UpdateAuthProperties.All", - "https://graph.microsoft.com/User.Read" - }; + private static readonly string[] RequiredScopes = AuthenticationConstants.BlueprintInteractiveAuthScopes; public InteractiveGraphAuthService( - ILogger logger, + ILogger logger, string clientAppId, Func? credentialFactory = null, Func>? loginHintResolver = null) @@ -122,7 +115,7 @@ public async Task GetAuthenticatedGraphClientAsync( } catch (Microsoft.Identity.Client.MsalServiceException ex) when (ex.ErrorCode == "access_denied") { - _logger.LogError("Authentication was denied or cancelled"); + _logger.LogDebug("Authentication was denied or cancelled by user (access_denied)"); throw new GraphApiException( "Interactive browser authentication", "Authentication was denied or cancelled by the user", @@ -130,7 +123,9 @@ public async Task GetAuthenticatedGraphClientAsync( } catch (Exception ex) { - _logger.LogError("Failed to authenticate to Microsoft Graph: {Message}", ex.Message); + var isCanceled = ex.Message.Contains("cancel", StringComparison.OrdinalIgnoreCase); + if (!isCanceled) + _logger.LogError("Failed to authenticate to Microsoft Graph: {Message}", ex.Message); throw new GraphApiException( "Browser authentication", $"Authentication failed: {ex.Message}", @@ -141,7 +136,6 @@ public async Task GetAuthenticatedGraphClientAsync( // MsalBrowserCredential caches the MSAL account, so subsequent GetTokenAsync calls // from GraphServiceClient will hit the silent cache without re-prompting. _logger.LogInformation("Successfully authenticated to Microsoft Graph!"); - _logger.LogInformation(""); var graphClient = new GraphServiceClient(credential!, RequiredScopes); _cachedClient = graphClient; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs index df1c41d0..1974f184 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs @@ -1,4 +1,7 @@ -namespace Microsoft.Agents.A365.DevTools.Cli.Services; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// /// Provides delegated access tokens for Microsoft Graph via PowerShell authentication. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs index a4caa792..96dabfab 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs @@ -159,13 +159,13 @@ public MicrosoftGraphTokenProvider( var result = await ExecuteWithFallbackAsync(script, ct); token = ProcessResult(result); - // If PowerShell browser auth was blocked by Conditional Access Policy, retry with - // device code. This covers the case where clientAppId is null (MSAL skipped) and the - // user is on a CAP-enforced tenant where browser auth is blocked. - if (string.IsNullOrWhiteSpace(token) && !useDeviceCode && IsConditionalAccessError(result)) + // If PowerShell browser auth was blocked (Conditional Access Policy or interactive + // browser unavailable in embedded terminal), retry with device code. + if (string.IsNullOrWhiteSpace(token) && !useDeviceCode && + (IsConditionalAccessError(result) || IsInteractiveBrowserFailure(result))) { _logger.LogWarning( - "PowerShell browser authentication blocked by a Conditional Access or device compliance policy (AADSTS53003/AADSTS53000). " + + "PowerShell interactive browser authentication failed (Conditional Access Policy or embedded terminal). " + "Retrying with device code authentication..."); var deviceCodeScript = BuildPowerShellScript(tenantId, validatedScopes, useDeviceCode: true, clientAppId); var deviceCodeResult = await ExecuteWithFallbackAsync(deviceCodeScript, ct); @@ -343,10 +343,17 @@ private async Task ExecuteWithFallbackAsync( _logger.LogDebug("Microsoft Graph access token acquired successfully."); return tokenResult.Token; } + catch (OperationCanceledException) + { + _logger.LogDebug("MSAL Graph token acquisition cancelled."); + return null; + } catch (Exception ex) { + var isCanceled = ex.Message.Contains("cancel", StringComparison.OrdinalIgnoreCase); _logger.LogDebug(ex, "MSAL Graph token acquisition failed"); - _logger.LogWarning("MSAL Graph token acquisition failed: {Message}", ex.Message); + if (!isCanceled) + _logger.LogWarning("MSAL Graph token acquisition failed: {Message}", ex.Message); return null; } } @@ -501,6 +508,13 @@ private static bool IsConditionalAccessError(CommandResult result) result.StandardError.Contains(AuthenticationConstants.DeviceCompliancePolicyBlockedError, StringComparison.Ordinal)); } + private static bool IsInteractiveBrowserFailure(CommandResult result) + { + return !string.IsNullOrWhiteSpace(result.StandardError) && + result.StandardError.Contains("InteractiveBrowserCredential authentication failed", + StringComparison.OrdinalIgnoreCase); + } + private static bool IsPowerShellNotFoundError(CommandResult result) { if (string.IsNullOrWhiteSpace(result.StandardError)) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs index f6230bdc..e2ef484b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs @@ -3,6 +3,7 @@ using Azure.Core; using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Extensions.Logging; using Microsoft.Identity.Client; @@ -37,6 +38,7 @@ public sealed class MsalBrowserCredential : TokenCredential { private readonly IPublicClientApplication _publicClientApp; private readonly ILogger? _logger; + private readonly string _clientAppId; private readonly string _tenantId; private readonly bool _useWam; private readonly IntPtr _windowHandle; @@ -104,6 +106,7 @@ public MsalBrowserCredential( throw new ArgumentNullException(nameof(tenantId)); } + _clientAppId = clientId; _tenantId = tenantId; _logger = logger; _loginHint = loginHint; @@ -351,12 +354,44 @@ public override async ValueTask GetTokenAsync( _logger?.LogDebug("Successfully acquired token from cache."); return new AccessToken(silentResult.AccessToken, silentResult.ExpiresOn); } - catch (MsalUiRequiredException) + catch (MsalUiRequiredException ex) { + if (ex.Classification == UiRequiredExceptionClassification.ConsentRequired) + LogConsentRequiredAndThrow(ex); _logger?.LogDebug("Token cache miss or expired, interactive authentication required."); } } + // Before showing interactive WAM: probe silently using the OS account. + // WAM can detect "Need admin approval" (consent required) without showing any dialog. + // If detected, print the admin consent URL and exit — WAM dialog is never shown. + if (_useWam) + { + try + { + _logger?.LogDebug("Probing consent status silently via WAM OS account..."); + var probeResult = await _publicClientApp + .AcquireTokenSilent(scopes, PublicClientApplication.OperatingSystemAccount) + .ExecuteAsync(cancellationToken); + _logger?.LogDebug("WAM OS account probe succeeded — consent is granted."); + // Only return the OS account token when no login hint is set. + // When a hint is provided, the caller wants a specific identity — fall through + // to interactive WAM with the hint so the correct user is authenticated. + if (string.IsNullOrWhiteSpace(_loginHint)) + return new AccessToken(probeResult.AccessToken, probeResult.ExpiresOn); + _logger?.LogDebug("Login hint set — skipping OS account token, proceeding to interactive WAM for {LoginHint}.", _loginHint); + } + catch (MsalUiRequiredException ex) when ( + ex.Classification == UiRequiredExceptionClassification.ConsentRequired) + { + LogConsentRequiredAndThrow(ex); + } + catch (MsalUiRequiredException) + { + // Interaction required for other reasons (first sign-in, MFA, etc.) — fall through to WAM. + } + } + // Acquire token interactively. // When a login hint is provided, WAM and browser auth will pre-select that identity // instead of defaulting to the Windows account or cached account picker. @@ -436,14 +471,59 @@ public override async ValueTask GetTokenAsync( aadErrorCode); return await AcquireTokenWithDeviceCodeFallbackAsync(scopes, cancellationToken); } + catch (MsalException ex) when (ex.Message.Contains(AuthenticationConstants.WamErrorPrefix, StringComparison.OrdinalIgnoreCase)) + { + // WAM error 0xcaa90019 = "Need admin approval" (admin consent not granted). + // Do NOT fall back to device code — device code shows the same browser consent page + // and hangs if the user clicks "Return to application without granting consent". + if (ex.Message.Contains(AuthenticationConstants.WamConsentRequiredError, StringComparison.OrdinalIgnoreCase)) + LogConsentRequiredAndThrow(ex); + + // Other WAM errors (e.g. Conditional Access Policy, device compliance policy) + // are not consent-related — device code flow bypasses the WAM broker and may succeed. + _logger?.LogWarning( + "WAM authentication blocked ({Error}). Falling back to device code authentication.", + ex.Message.Split('\n').FirstOrDefault(l => l.Contains("0xcaa", StringComparison.OrdinalIgnoreCase))?.Trim() ?? "WAM error"); + return await AcquireTokenWithDeviceCodeFallbackAsync(scopes, cancellationToken); + } catch (MsalException ex) { _logger?.LogDebug(ex, "MSAL authentication failed"); - _logger?.LogError("MSAL authentication failed: {Message}", ex.Message); + if (ex.Message.Contains("cancel", StringComparison.OrdinalIgnoreCase) || + ex.ErrorCode is "authentication_canceled" or "user_canceled") + { + _logger?.LogDebug("Sign-in was canceled."); + } + else + { + _logger?.LogError("MSAL authentication failed: {Message}", ex.Message); + } throw new MsalAuthenticationFailedException($"Failed to acquire token: {ex.Message}", ex); } } + /// + /// Logs a consistent "admin consent required" message with the admin consent URL and throws. + /// Used by all three consent-detection points: silent path, WAM OS probe, and WAM error backstop. + /// + private void LogConsentRequiredAndThrow(Exception inner) + { + var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(_clientAppId, _tenantId); + _logger?.LogWarning("Admin consent has not been granted for this application."); + _logger?.LogWarning("You are running as a non-admin user and cannot grant admin consent."); + if (consentUrl != null) + { + _logger?.LogWarning("Share this URL with a Global Administrator to grant consent:"); + _logger?.LogWarning(" {ConsentUrl}", consentUrl); + } + _logger?.LogWarning("After consent is granted, re-run the command."); + throw new MsalAuthenticationFailedException( + consentUrl != null + ? $"Admin consent required. Share this URL with a Global Administrator: {consentUrl}" + : "Admin consent required. A Global Administrator must grant tenant-wide consent for this application.", + inner); + } + private async Task AcquireTokenWithDeviceCodeFallbackAsync( string[] scopes, CancellationToken cancellationToken) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/NonInteractiveConfirmationProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/NonInteractiveConfirmationProvider.cs new file mode 100644 index 00000000..fdad004c --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/NonInteractiveConfirmationProvider.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Confirmation provider that automatically approves all prompts. +/// Used when --yes is passed to skip interactive confirmation. +/// +internal sealed class NonInteractiveConfirmationProvider : IConfirmationProvider +{ + public Task ConfirmAsync(string prompt) => Task.FromResult(true); + + public Task ConfirmWithTypedResponseAsync(string prompt, string expectedResponse) => Task.FromResult(true); +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs index 191900fa..0ee8fac7 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs @@ -28,7 +28,7 @@ public abstract class RequirementCheck : IRequirementCheck /// protected virtual void LogCheckSuccess(ILogger logger, string? details = null) { - logger.LogInformation("[PASS] {Name}{Details}", Name, + logger.LogInformation("Pass: {Name}{Details}", Name, string.IsNullOrWhiteSpace(details) ? "" : $" ({details})"); } @@ -37,7 +37,7 @@ protected virtual void LogCheckSuccess(ILogger logger, string? details = null) /// protected virtual void LogCheckWarning(ILogger logger, string? message = null) { - logger.LogWarning("[WARN] {Name}{Details}", Name, + logger.LogWarning("Warn: {Name}{Details}", Name, string.IsNullOrWhiteSpace(message) ? "" : $" - {message}"); } @@ -46,14 +46,13 @@ protected virtual void LogCheckWarning(ILogger logger, string? message = null) /// protected virtual void LogCheckFailure(ILogger logger, string errorMessage, string resolutionGuidance) { - // Name logged at Error level (red) — formatter already prefixes ERROR: - logger.LogError("[FAIL] {Name}", Name); + // Single red line — AZ CLI convention: one ERROR line per failure, not per detail + logger.LogError("Fail: {Name}", Name); - // Error details in red (split multi-line messages into separate lines) + // Error details and resolution guidance in white — they describe and guide, not error foreach (var line in errorMessage.Split('\n', StringSplitOptions.RemoveEmptyEntries)) - logger.LogError(" {Line}", line.TrimEnd()); + logger.LogInformation(" {Line}", line.TrimEnd()); - // Resolution guidance in white (not red) — it is helpful guidance, not an error if (!string.IsNullOrWhiteSpace(resolutionGuidance)) { logger.LogInformation(""); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/design.md b/src/Microsoft.Agents.A365.DevTools.Cli/design.md index 60344271..5015af32 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/design.md +++ b/src/Microsoft.Agents.A365.DevTools.Cli/design.md @@ -232,45 +232,39 @@ Only `GetAccessTokenAsync` and `ResolveLoginHintFromCacheAsync` are on the inter ## Command Pattern Implementation -Commands follow the Spectre.Console `AsyncCommand` pattern: +Commands use `System.CommandLine` with a static factory method pattern. Each command exposes a `CreateCommand(...)` method that receives its dependencies as explicit parameters and returns a `Command` object wired with a `SetHandler` lambda. ```csharp -public class SetupCommand : AsyncCommand +// Factory method receives dependencies explicitly — no reflection-based DI +internal static Command CreateCommand( + ILogger logger, + IConfigService configService, + GraphApiService graphApiService, + ...) { - private readonly ILogger _logger; - private readonly IConfigService _configService; + var command = new Command("setup", "..."); + var configOption = new Option(["--config", "-c"], ...); + command.AddOption(configOption); - public SetupCommand(ILogger logger, IConfigService configService) + command.SetHandler(async (InvocationContext context) => { - _logger = logger; - _configService = configService; - } + var config = context.ParseResult.GetValueForOption(configOption)!; + var ct = context.GetCancellationToken(); - public class Settings : CommandSettings - { - [CommandOption("--config")] - [Description("Path to configuration file")] - public string? ConfigFile { get; init; } - - [CommandOption("--non-interactive")] - [Description("Run without interactive prompts")] - public bool NonInteractive { get; init; } - } - - public override async Task ExecuteAsync(CommandContext context, Settings settings) - { - _logger.LogInformation("Starting setup..."); // Implementation - return 0; // Success - } + context.ExitCode = 0; + }); + + return command; } ``` **Guidelines:** -- Keep commands thin - delegate business logic to services -- Use dependency injection for services -- Return 0 for success, non-zero for errors (use `ErrorCodes`) -- Log progress with `ILogger` and structured placeholders +- Keep command handlers thin — delegate business logic to services or orchestrators +- Dependencies are passed as constructor-style parameters to `CreateCommand` +- Exit code: 0 = success, 1 = failure (set via `context.ExitCode` or `Environment.Exit`) +- Log progress with `ILogger` and structured placeholders (`{Name}` syntax) +- Dry-run guard: check `dryRun` flag before any mutating work --- @@ -457,29 +451,79 @@ Because the two permission layers require different roles, the CLI supports a tw The entry point handles: -1. **Logging Configuration** - Serilog with console and file sinks -2. **Dependency Injection** - Service registration via `IServiceCollection` -3. **Command Registration** - Commands registered with Spectre.Console.Cli -4. **Exception Handling** - Global exception handler with user-friendly messages +1. **Logging Configuration** - `Microsoft.Extensions.Logging` with clean console and file sinks (per-command log file under `%LocalAppData%`) +2. **Service Resolution** - Services are registered in a DI container (ServiceCollection/ServiceProvider) and passed to `CreateCommand` factory methods +3. **Command Registration** - `System.CommandLine` `RootCommand` with subcommands added via `AddCommand` +4. **Exception Handling** - `CommandLineBuilder` middleware + `ExceptionHandler` for user-friendly messages ```csharp // Simplified structure -var services = new ServiceCollection(); -services.AddSingleton(); -services.AddSingleton(); +var loggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(logLevel); +var configService = new ConfigService(loggerFactory.CreateLogger()); +var graphApiService = new GraphApiService(...); // ... more services -var app = new CommandApp(new TypeRegistrar(services)); -app.Configure(config => -{ - config.AddCommand("config"); - config.AddCommand("setup"); - config.AddCommand("deploy"); - // ... more commands -}); +var rootCommand = new RootCommand("a365 — Microsoft Agent 365 CLI"); +rootCommand.AddCommand(SetupCommand.CreateCommand(logger, configService, ...)); +rootCommand.AddCommand(DeployCommand.CreateCommand(logger, configService, ...)); +// ... more commands + +return await new CommandLineBuilder(rootCommand) + .UseDefaults() + .Build() + .InvokeAsync(args); +``` + +--- + +## Setup Workflow Architecture + +### Two Agent Flows + +`a365 setup all` supports two distinct agent types, controlled by `--aiteammate` (CLI) or `aiTeammate` (config): + +| Agent Type | Flag | What it creates | +|---|---|---| +| **Digital Worker** (default) | `--aiteammate true` or omit | Azure infra + Agent Blueprint + batch permissions (5 resources) + messaging endpoint | +| **Custom Engine Agent / Blueprint** | `--aiteammate false` | Agent Blueprint + batch permissions (Graph + A365 Tools only) + Agent Instance (Graph API) | + +Non-DW blueprint agents do not use Azure Bot Service, so there is no infrastructure step, no manifest zip, and no messaging endpoint registration. The final step is `POST /beta/agentRegistry/agentInstances` instead. + +### SetupContext — Shared Step State + +`SetupContext` is a bundle of mutable state and services passed to each extracted step method. It enables the non-DW orchestrator to reuse the same step implementations as the DW flow without duplicating code. -return await app.RunAsync(args); ``` +AllSubcommand.ExecuteAsync + │ + ├── builds SetupContext (config, results, logger, services) + │ + ├── DW path: + │ ExecuteInfrastructureStepAsync(ctx) ← DW only + │ ExecuteBlueprintStepAsync(ctx) ← shared + │ ExecuteBatchPermissionsStepAsync(ctx, dwSpecs) ← shared (5 resources) + │ ExecuteMessagingEndpointStepAsync(ctx) ← DW only + │ + └── Non-DW path: + NonDwBlueprintSetupOrchestrator.ExecuteAsync(ctx) + ExecuteBlueprintStepAsync(ctx) ← reuses DW step + ExecuteBatchPermissionsStepAsync(ctx, nonDwSpecs) ← reuses, 2 resources only + RegisterAgentInstanceAsync(...) ← non-DW final step +``` + +`SetupContext.Config` is intentionally mutable — the blueprint step reloads configuration from disk after writing `AgentBlueprintId`, and the updated instance must be visible to all subsequent steps. + +### Batch Permissions — Resource Specs + +The non-DW spec list is a strict subset of the DW list: + +| Resource | DW | Non-DW Blueprint | +|---|---|---| +| Microsoft Graph (delegated) | ✓ | — | +| Agent 365 Tools (delegated) | ✓ | — | +| Messaging Bot API | ✓ | — | +| Observability API | ✓ | ✓ | +| Power Platform API | ✓ | ✓ | --- diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs new file mode 100644 index 00000000..88dc3048 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Unit tests for AllSubcommand helpers. +/// +public class AllSubcommandTests : IDisposable +{ + private readonly string _tempDir; + + public AllSubcommandTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "AllSubcommandTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() => Directory.Delete(_tempDir, recursive: true); + + // ----------------------------------------------------------------------- + // BackupAndClearStaleConfigAsync + // ----------------------------------------------------------------------- + + [Fact] + public async Task BackupAndClearStaleConfig_WhenTenantMatches_LeavesFilesUntouched() + { + var configPath = Path.Combine(_tempDir, "a365.config.json"); + File.WriteAllText(configPath, """{"tenantId": "same-tenant"}"""); + + await AllSubcommand.BackupAndClearStaleConfigAsync(configPath, "same-tenant", NullLogger.Instance); + + File.Exists(configPath).Should().BeTrue( + because: "files must not be touched when the tenant matches"); + Directory.GetFiles(_tempDir, "*.bak.*").Should().BeEmpty( + because: "no backup should be created when the tenant is the same"); + } + + [Fact] + public async Task BackupAndClearStaleConfig_WhenConfigFileAbsent_DoesNothing() + { + var configPath = Path.Combine(_tempDir, "a365.config.json"); + // deliberately not created + + await AllSubcommand.BackupAndClearStaleConfigAsync(configPath, "new-tenant", NullLogger.Instance); + + Directory.GetFiles(_tempDir).Should().BeEmpty( + because: "nothing should be written when no config file exists"); + } + + [Fact] + public async Task BackupAndClearStaleConfig_WhenTenantDiffers_BacksUpConfigAndRemovesOriginal() + { + var configPath = Path.Combine(_tempDir, "a365.config.json"); + File.WriteAllText(configPath, """{"tenantId": "old-tenant"}"""); + + await AllSubcommand.BackupAndClearStaleConfigAsync(configPath, "new-tenant", NullLogger.Instance); + + File.Exists(configPath).Should().BeFalse( + because: "the original config file must be removed when the tenant differs"); + Directory.GetFiles(_tempDir, "a365.config.json.bak.*").Should().HaveCount(1, + because: "the old config must be backed up with a timestamp suffix"); + } + + [Fact] + public async Task BackupAndClearStaleConfig_WhenTenantDiffers_AlsoBacksUpGeneratedConfig() + { + var configPath = Path.Combine(_tempDir, "a365.config.json"); + var generatedPath = Path.Combine(_tempDir, "a365.generated.config.json"); + File.WriteAllText(configPath, """{"tenantId": "old-tenant"}"""); + File.WriteAllText(generatedPath, """{"agentBlueprintId": "bp-from-old-tenant"}"""); + + await AllSubcommand.BackupAndClearStaleConfigAsync(configPath, "new-tenant", NullLogger.Instance); + + File.Exists(generatedPath).Should().BeFalse( + because: "the generated config must also be removed when the tenant differs"); + Directory.GetFiles(_tempDir, "a365.generated.config.json.bak.*").Should().HaveCount(1, + because: "the generated config must be backed up alongside the static config"); + } + + [Fact] + public async Task BackupAndClearStaleConfig_WhenTenantDiffersButNoGeneratedConfig_OnlyBacksUpStaticConfig() + { + var configPath = Path.Combine(_tempDir, "a365.config.json"); + File.WriteAllText(configPath, """{"tenantId": "old-tenant"}"""); + // deliberately no a365.generated.config.json + + await AllSubcommand.BackupAndClearStaleConfigAsync(configPath, "new-tenant", NullLogger.Instance); + + Directory.GetFiles(_tempDir, "a365.config.json.bak.*").Should().HaveCount(1, + because: "the static config must be backed up"); + Directory.GetFiles(_tempDir, "a365.generated.config.json.bak.*").Should().BeEmpty( + because: "no generated config backup should be created when the file did not exist"); + } + + [Fact] + public async Task BackupAndClearStaleConfig_WhenConfigIsMalformedJson_BacksUpAsIfMismatch() + { + var configPath = Path.Combine(_tempDir, "a365.config.json"); + File.WriteAllText(configPath, "this is not valid json"); + + await AllSubcommand.BackupAndClearStaleConfigAsync(configPath, "new-tenant", NullLogger.Instance); + + File.Exists(configPath).Should().BeFalse( + because: "a malformed config file cannot be trusted and must be backed up"); + Directory.GetFiles(_tempDir, "a365.config.json.bak.*").Should().HaveCount(1); + } + + [Fact] + public async Task BackupAndClearStaleConfig_TenantComparisonIsCaseInsensitive() + { + var configPath = Path.Combine(_tempDir, "a365.config.json"); + File.WriteAllText(configPath, """{"tenantId": "TENANT-ABC"}"""); + + await AllSubcommand.BackupAndClearStaleConfigAsync(configPath, "tenant-abc", NullLogger.Instance); + + File.Exists(configPath).Should().BeTrue( + because: "tenant ID comparison must be case-insensitive"); + Directory.GetFiles(_tempDir, "*.bak.*").Should().BeEmpty( + because: "no backup should be created when tenants match case-insensitively"); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs index 4217fff6..fd3e0ff6 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs @@ -476,7 +476,7 @@ public async Task CreateBlueprintImplementation_ShouldLogProgressMessages() _mockLogger.Received().Log( LogLevel.Information, Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Creating Agent Blueprint")), + Arg.Is(o => o.ToString()!.Contains("Creating agent blueprint")), Arg.Any(), Arg.Any>()); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs index f6f4768c..8a9ae1c7 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs @@ -368,8 +368,8 @@ await spyService.Received(1).DeleteAgentBlueprintAsync( } /// - /// Verifies that blueprint cleanup with no instances proceeds exactly as before - /// (no instance deletion calls made). + /// Verifies that blueprint cleanup with no DW instances still deletes agent identity + /// when AgenticAppId is present (data-driven cleanup — no IsNonDwBlueprint flag required). /// [Fact] public async Task CleanupBlueprint_WithNoInstances_ProceedsAsNormal() @@ -378,6 +378,7 @@ public async Task CleanupBlueprint_WithNoInstances_ProceedsAsNormal() var config = CreateValidConfig(); // Capture blueprint ID before the command clears it during config save var expectedBlueprintId = config.AgentBlueprintId!; + var expectedIdentityId = config.AgenticAppId!; _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) @@ -398,10 +399,16 @@ public async Task CleanupBlueprint_WithNoInstances_ProceedsAsNormal() // Assert result.Should().Be(0); + // No DW agentic users to delete (no instances) await spyService.DidNotReceive().DeleteAgentUserAsync( Arg.Any(), Arg.Any(), Arg.Any()); - await spyService.DidNotReceive().DeleteAgentIdentityAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + + // Requirement: CleanupCommand must always delete the agent identity when AgenticAppId is present, + // regardless of DW/non-DW path — deletion is data-driven (config presence), not flag-based. + // Previously this test asserted DidNotReceive; the requirement changed when the non-DW blueprint + // path was added and identity deletion was unified across both paths. + await spyService.Received(1).DeleteAgentIdentityAsync( + config.TenantId, expectedIdentityId, Arg.Any()); await spyService.Received(1).DeleteAgentBlueprintAsync( config.TenantId, expectedBlueprintId, Arg.Any()); @@ -1023,6 +1030,135 @@ await _mockBotConfigurator.DidNotReceive().DeleteEndpointWithAgentBlueprintAsync } } + /// + /// Verifies the Entra-discovery fallback in ExecuteAllCleanupAsync: + /// when AgenticAppId is absent from config, linked SPs are discovered via + /// GetAgentInstancesForBlueprintAsync and deleted. This covers the bug where + /// 'a365 cleanup' without '--agent-name' silently skipped agent identity deletion + /// because AgenticAppId was not populated in config. + /// + [Fact] + public async Task ExecuteAllCleanup_WhenAgenticAppIdEmpty_DeletesLinkedSpDiscoveredFromEntra() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant-id", + AgentBlueprintId = "test-blueprint-id", + AgenticAppId = null // Not in config — Entra discovery path must pick it up + }; + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); + + var linkedInstance = new AgentInstanceInfo { IdentitySpId = "sp-entra-id", DisplayName = "Entra SP" }; + var stubbedBlueprintService = CreateStubbedBlueprintService( + instances: new List { linkedInstance }, + deleteIdentityResult: true, + deleteBlueprintResult: true); + + var command = CleanupCommand.CreateCommand( + _mockLogger, _mockConfigService, _mockBotConfigurator, + _mockExecutor, stubbedBlueprintService, _mockConfirmationProvider, _federatedCredentialService, + _mockAuthValidator, graphApiService: _graphApiService); + var args = new[] { "cleanup", "--config", "test.json" }; + + // Act + var result = await command.InvokeAsync(args); + + // Assert + result.Should().Be(0); + // Requirement: when AgenticAppId is absent from config, the Entra-discovery path must locate + // and delete linked identity SPs — previously they were silently skipped. + await stubbedBlueprintService.Received(1).DeleteAgentIdentityAsync( + config.TenantId, "sp-entra-id", Arg.Any()); + } + + /// + /// Verifies that when the same SP appears in both config.AgenticAppId and the Entra query + /// result, DeleteAgentIdentityAsync is called only once — the deletedIdentityIds HashSet + /// deduplicates it to prevent double-delete. + /// + [Fact] + public async Task ExecuteAllCleanup_WhenSpInBothConfigAndEntra_DeletesIdentityOnlyOnce() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant-id", + AgentBlueprintId = "test-blueprint-id", + AgenticAppId = "sp-config-id" // Same ID as Entra result below + }; + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); + + // Entra returns the same SP that is already in config — dedup must prevent double-delete. + var linkedInstance = new AgentInstanceInfo { IdentitySpId = "sp-config-id", DisplayName = "Config SP" }; + var stubbedBlueprintService = CreateStubbedBlueprintService( + instances: new List { linkedInstance }, + deleteIdentityResult: true, + deleteBlueprintResult: true); + + var command = CleanupCommand.CreateCommand( + _mockLogger, _mockConfigService, _mockBotConfigurator, + _mockExecutor, stubbedBlueprintService, _mockConfirmationProvider, _federatedCredentialService, + _mockAuthValidator, graphApiService: _graphApiService); + var args = new[] { "cleanup", "--config", "test.json" }; + + // Act + var result = await command.InvokeAsync(args); + + // Assert + result.Should().Be(0); + // Requirement: deletedIdentityIds dedup must prevent double-deletes when the same SP appears + // in both config.AgenticAppId and GetAgentInstancesForBlueprintAsync results. + await stubbedBlueprintService.Received(1).DeleteAgentIdentityAsync( + config.TenantId, "sp-config-id", Arg.Any()); + } + + /// + /// Verifies that when GetAgentInstancesForBlueprintAsync throws, the exception is swallowed + /// and the overall cleanup continues — the Entra discovery path is non-fatal. + /// + [Fact] + public async Task ExecuteAllCleanup_WhenEntraQueryThrows_CleanupContinuesNonfatally() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant-id", + AgentBlueprintId = "test-blueprint-id", + AgenticAppId = null + }; + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); + + // Build stub manually so the query can be configured to throw. + var mockBlueprintLogger = Substitute.For>(); + var stubbedBlueprintService = Substitute.ForPartsOf(mockBlueprintLogger, _graphApiService); + stubbedBlueprintService.GetAgentInstancesForBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException>( + new InvalidOperationException("Simulated Entra query failure"))); + stubbedBlueprintService.DeleteAgentIdentityAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + stubbedBlueprintService.DeleteAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + var command = CleanupCommand.CreateCommand( + _mockLogger, _mockConfigService, _mockBotConfigurator, + _mockExecutor, stubbedBlueprintService, _mockConfirmationProvider, _federatedCredentialService, + _mockAuthValidator, graphApiService: _graphApiService); + var args = new[] { "cleanup", "--config", "test.json" }; + + // Act + var result = await command.InvokeAsync(args); + + // Assert + result.Should().Be(0, because: "Entra discovery failure is non-fatal; cleanup must complete"); + // AgenticAppId was empty and Entra query threw — no identity deletion should have occurred. + await stubbedBlueprintService.DidNotReceive().DeleteAgentIdentityAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + /// /// Verifies that blueprint cleanup with --endpoint-only flag handles empty input (just Enter). /// When user presses Enter without typing anything, cleanup should be cancelled (default is No). diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandTests.cs index 59028c51..2e4402f3 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandTests.cs @@ -165,6 +165,7 @@ public async Task Display_WithGeneratedFlag_ShowsGeneratedConfig() var minimalStaticConfig = new { tenantId = "12345678-1234-1234-1234-123456789012", + clientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", subscriptionId = "87654321-4321-4321-4321-210987654321", resourceGroup = "test-rg", location = "eastus", @@ -225,12 +226,14 @@ public async Task Display_PrefersLocalConfigOverGlobal() var globalConfig = new { tenantId = "11111111-1111-1111-1111-111111111111", + clientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", subscriptionId = "22222222-2222-2222-2222-222222222222", resourceGroup = "global-rg", location = "eastus", appServicePlanName = "global-plan", webAppName = "global-app", - agentIdentityDisplayName = "Global Agent" + agentIdentityDisplayName = "Global Agent", + deploymentProjectPath = configDir }; await File.WriteAllTextAsync(globalConfigPath, JsonSerializer.Serialize(globalConfig)); @@ -239,12 +242,14 @@ public async Task Display_PrefersLocalConfigOverGlobal() var localConfig = new { tenantId = "33333333-3333-3333-3333-333333333333", + clientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", subscriptionId = "44444444-4444-4444-4444-444444444444", resourceGroup = "local-rg", location = "eastus", appServicePlanName = "local-plan", webAppName = "local-app", - agentIdentityDisplayName = "Local Agent" + agentIdentityDisplayName = "Local Agent", + deploymentProjectPath = localDir }; await File.WriteAllTextAsync(localConfigPath, JsonSerializer.Serialize(localConfig)); @@ -291,6 +296,7 @@ public async Task Display_WithGeneratedFlag_ShowsOnlyGeneratedConfig() var minimalStaticConfig = new { tenantId = "12345678-1234-1234-1234-123456789012", + clientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", subscriptionId = "87654321-4321-4321-4321-210987654321", resourceGroup = "test-rg", location = "eastus", @@ -347,6 +353,7 @@ public async Task Display_WithAllFlag_ShowsBothConfigs() var minimalStaticConfig = new { tenantId = "12345678-1234-1234-1234-123456789012", + clientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", subscriptionId = "87654321-4321-4321-4321-210987654321", resourceGroup = "test-rg", location = "eastus", diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigPermissionsSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigPermissionsSubcommandTests.cs index 731b6820..620994dc 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigPermissionsSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigPermissionsSubcommandTests.cs @@ -390,31 +390,27 @@ public async Task Add_UsesLocalConfigWhenBothExist_DoesNotModifyGlobal() } [Fact] - public async Task Add_NoLocalConfig_UsesGlobalConfig() + public async Task Add_NoLocalConfig_ReturnsError() { + // Global config fallback was removed: the command must only read from the current directory. + // A stale global config from a different project must not be silently used. var logger = _loggerFactory.CreateLogger("Test"); var globalDir = GetTestConfigDir(); - var emptyLocalDir = GetTestConfigDir(); // no config file here + var emptyLocalDir = GetTestConfigDir(); // no a365.config.json here - var globalConfigPath = await CreateConfigFileAsync(globalDir, new { tenantId = "global-tenant" }); + await CreateConfigFileAsync(globalDir, new { tenantId = "global-tenant" }); var originalDir = Environment.CurrentDirectory; - var originalOut = Console.Out; - using var output = new StringWriter(); try { Environment.CurrentDirectory = emptyLocalDir; - Console.SetOut(output); var root = await BuildRootCommandAsync(logger, globalDir, _mockWizardService); var result = await root.InvokeAsync($"config permissions --resource-app-id {ValidGuid} --scopes User.Read"); - result.Should().Be(0); - var globalJson = await File.ReadAllTextAsync(globalConfigPath); - globalJson.Should().Contain(ValidGuid); + result.Should().Be(1, because: "config permissions must fail when no a365.config.json exists in the current directory"); } finally { - Console.SetOut(originalOut); Environment.CurrentDirectory = originalDir; await CleanupAsync(globalDir); await CleanupAsync(emptyLocalDir); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwBlueprintSetupOrchestratorDryRunTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwBlueprintSetupOrchestratorDryRunTests.cs new file mode 100644 index 00000000..25275d41 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwBlueprintSetupOrchestratorDryRunTests.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Tests for NonDwBlueprintSetupOrchestrator.PrintDryRunPlan. +/// Assertions pin requirements (what information must appear), not presentation +/// (how it is phrased), so that wording changes do not cause false failures. +/// +public class NonDwBlueprintSetupOrchestratorDryRunTests +{ + private readonly ILogger _logger = Substitute.For(); + + private static Agent365Config BuildConfig( + string displayName = "My Agent", + string tenantId = "tenant-id", + string? blueprintId = null) => + new() + { + AgentIdentityDisplayName = displayName, + TenantId = tenantId, + AiTeammate = false, + UseBlueprint = true, + SubscriptionId = "sub-id", + ClientAppId = "client-app-id", + Location = "eastus", + DeploymentProjectPath = "./app", + AgentBlueprintId = blueprintId + }; + + private bool AnyLogContains(string value) => + _logger.ReceivedCalls() + .Any(c => c.GetArguments()[2]?.ToString()?.Contains(value, StringComparison.OrdinalIgnoreCase) == true); + + [Fact] + public void PrintDryRunPlan_LogsHeader() + { + NonDwBlueprintSetupOrchestrator.PrintDryRunPlan(BuildConfig(), _logger); + + // Header identifies this as a dry run + AnyLogContains("Dry run").Should().BeTrue(because: "output must identify itself as a dry run"); + } + + [Fact] + public void PrintDryRunPlan_IncludesRunWithoutDryRunInstruction() + { + NonDwBlueprintSetupOrchestrator.PrintDryRunPlan(BuildConfig(), _logger); + + // Footer tells the user how to execute for real + AnyLogContains("--dry-run").Should().BeTrue(because: "footer must tell the user to run without --dry-run"); + } + + [Fact] + public void PrintDryRunPlan_WithoutExistingBlueprint_ShowsCreate() + { + NonDwBlueprintSetupOrchestrator.PrintDryRunPlan(BuildConfig(blueprintId: null), _logger); + + // Blueprint creation path: must mention multi-tenant (factual attribute of the app registration) + AnyLogContains("multi-tenant").Should().BeTrue(because: "new blueprint is created as multi-tenant"); + } + + [Fact] + public void PrintDryRunPlan_WithExistingBlueprint_ShowsReuse() + { + NonDwBlueprintSetupOrchestrator.PrintDryRunPlan(BuildConfig(blueprintId: "existing-bp-id"), _logger); + + // Reuse path: must surface the existing blueprint ID so the user can verify + AnyLogContains("existing-bp-id").Should().BeTrue(because: "existing blueprint ID must appear so the user can verify the correct one is used"); + } + + [Fact] + public void PrintDryRunPlan_WithExistingBlueprint_DoesNotShowCreate() + { + NonDwBlueprintSetupOrchestrator.PrintDryRunPlan(BuildConfig(blueprintId: "existing-bp-id"), _logger); + + // Reuse path must not imply a new blueprint will be created + AnyLogContains("multi-tenant").Should().BeFalse(because: "reuse path must not suggest a new blueprint will be created"); + } + + [Fact] + public void PrintDryRunPlan_IncludesDisplayName() + { + NonDwBlueprintSetupOrchestrator.PrintDryRunPlan(BuildConfig(displayName: "Contoso Agent"), _logger); + + AnyLogContains("Contoso Agent").Should().BeTrue(because: "agent display name must appear so the user can confirm the correct agent"); + } + + [Fact] + public void PrintDryRunPlan_IncludesObservabilityApiPermissions() + { + NonDwBlueprintSetupOrchestrator.PrintDryRunPlan(BuildConfig(), _logger); + + AnyLogContains("Observability API").Should().BeTrue(because: "Observability API is required for non-DW blueprints to write OpenTelemetry data"); + } + + [Fact] + public void PrintDryRunPlan_IncludesPowerPlatformApiPermissions() + { + NonDwBlueprintSetupOrchestrator.PrintDryRunPlan(BuildConfig(), _logger); + + AnyLogContains("Power Platform API").Should().BeTrue(because: "Power Platform API is required for non-DW blueprints"); + } + + [Fact] + public void PrintDryRunPlan_DoesNotIncludeMessagingBotApi() + { + NonDwBlueprintSetupOrchestrator.PrintDryRunPlan(BuildConfig(), _logger); + + AnyLogContains("Messaging Bot API").Should().BeFalse(because: "Messaging Bot API is DW-only and must not appear in non-DW dry-run output"); + } + + [Fact] + public void PrintDryRunPlan_DoesNotIncludeMicrosoftGraph() + { + NonDwBlueprintSetupOrchestrator.PrintDryRunPlan(BuildConfig(), _logger); + + AnyLogContains("Microsoft Graph").Should().BeFalse(because: "Microsoft Graph is DW-only and must not appear in non-DW dry-run output"); + } + + [Fact] + public void PrintDryRunPlan_IncludesAgentRegistrationStep() + { + NonDwBlueprintSetupOrchestrator.PrintDryRunPlan(BuildConfig(), _logger); + + AnyLogContains("Agent Registration").Should().BeTrue(because: "agent registration is a required setup step"); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwBlueprintSetupOrchestratorExecuteTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwBlueprintSetupOrchestratorExecuteTests.cs new file mode 100644 index 00000000..c3f1ac08 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwBlueprintSetupOrchestratorExecuteTests.cs @@ -0,0 +1,370 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Tests for NonDwBlueprintSetupOrchestrator.ExecuteAsync — Phase B setup execution. +/// +/// Behavioral coverage: +/// - Blueprint failure results in exit code 1 and populated errors +/// - Agent instance ID is recorded on success +/// +/// Note: The full success path (blueprint created → batch permissions → agent instance registered) +/// requires an integration test harness because BlueprintSubcommand.CreateBlueprintImplementationAsync +/// is a static method with many Graph API calls. Those tests are tracked separately. +/// +public class NonDwBlueprintSetupOrchestratorExecuteTests +{ + // ------------------------------------------------------------------------- + // ExecuteAsync behavioral tests — error paths + // ------------------------------------------------------------------------- + + private static CommandExecutor BuildMockExecutor() + { + var executor = Substitute.For(Substitute.For>()); + executor.ExecuteAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new CommandResult + { + ExitCode = 0, + StandardOutput = string.Empty, + StandardError = string.Empty + })); + return executor; + } + + private static SetupContext BuildContext(Agent365Config? config = null, bool skipRequirements = true) + { + var cfg = config ?? new Agent365Config + { + AiTeammate = false, + TenantId = "tenant-id", + AgentIdentityDisplayName = "Test Agent", + ClientAppId = "client-app-id", + Location = "eastus", + SubscriptionId = "sub-id", + }; + + var mockExecutor = BuildMockExecutor(); + + // Use ForPartsOf so virtual methods return null/default without triggering real logic + Func> noOpLoginHint = () => Task.FromResult(null); + var graphApiService = Substitute.ForPartsOf( + Substitute.For>(), + mockExecutor, + Substitute.For(), + (System.Net.Http.HttpMessageHandler?)null, + (IMicrosoftGraphTokenProvider?)null, + noOpLoginHint, + (string?)null, + (RetryHelper?)null, + (TimeSpan?)TimeSpan.Zero); + + var blueprintService = Substitute.ForPartsOf( + Substitute.For>(), + graphApiService); + + var blueprintLookupService = Substitute.ForPartsOf( + Substitute.For>(), + graphApiService); + + var federatedCredentialService = Substitute.ForPartsOf( + Substitute.For>(), + graphApiService); + + var authValidator = Substitute.For( + NullLogger.Instance, mockExecutor); + + var configService = Substitute.For(); + // LoadAsync returns a config with blueprint ID so the reload after blueprint step + // does not throw a SetupValidationException about missing AgentBlueprintId. + configService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(new Agent365Config + { + AiTeammate = false, + TenantId = cfg.TenantId, + AgentIdentityDisplayName = cfg.AgentIdentityDisplayName, + AgentBlueprintId = "test-blueprint-id", + ClientAppId = cfg.ClientAppId, + }); + configService.SaveStateAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + return new SetupContext( + config: cfg, + results: new SetupResults(), + logger: Substitute.For(), + configFile: new FileInfo("a365.config.json"), + generatedConfigPath: "a365.generated.config.json", + correlationId: "test-correlation-id", + skipInfrastructure: true, + skipRequirements: skipRequirements, + cancellationToken: CancellationToken.None, + configService: configService, + executor: mockExecutor, + botConfigurator: Substitute.For(), + authValidator: authValidator, + platformDetector: Substitute.ForPartsOf( + Substitute.For>()), + graphApiService: graphApiService, + blueprintService: blueprintService, + blueprintLookupService: blueprintLookupService, + federatedCredentialService: federatedCredentialService, + clientAppValidator: Substitute.For(), + loginHintResolver: () => Task.FromResult(null)); + } + + /// + /// When blueprint creation fails (which it will with mocked services returning null), + /// ExecuteAsync must return exit code 1 — never throw. + /// + [Fact] + public async Task ExecuteAsync_ReturnsExitCode1_WhenBlueprintFails() + { + var ctx = BuildContext(); + + var exitCode = await NonDwBlueprintSetupOrchestrator.ExecuteAsync(ctx); + + exitCode.Should().Be(1); + } + + /// + /// When blueprint creation fails, errors must be added to SetupResults + /// so the summary display can show what went wrong. + /// + [Fact] + public async Task ExecuteAsync_AddsErrors_WhenBlueprintFails() + { + var ctx = BuildContext(); + + await NonDwBlueprintSetupOrchestrator.ExecuteAsync(ctx); + + ctx.Results.HasErrors.Should().BeTrue(); + } + + /// + /// When SkipRequirements is true, the requirements check step must be skipped entirely. + /// The setup will still fail at blueprint creation (mocked services), but it must not + /// fail on requirements validation. + /// + [Fact] + public async Task ExecuteAsync_DoesNotRunRequirementsCheck_WhenSkipRequirementsIsTrue() + { + var ctx = BuildContext(skipRequirements: true); + + // This must not throw a requirements-related exception even with partial mocks + var exitCode = await NonDwBlueprintSetupOrchestrator.ExecuteAsync(ctx); + + // Blueprint fails → exit 1, but NOT due to requirements check + exitCode.Should().Be(1); + } + + /// + /// AgentInstanceRegistered must be false when the blueprint step fails + /// (agent instance registration is not attempted if blueprint creation fails). + /// + [Fact] + public async Task ExecuteAsync_AgentInstanceNotRegistered_WhenBlueprintFails() + { + var ctx = BuildContext(); + + await NonDwBlueprintSetupOrchestrator.ExecuteAsync(ctx); + + ctx.Results.AgentInstanceRegistered.Should().BeFalse(); + ctx.Results.AgentInstanceId.Should().BeNull(); + } + + // ------------------------------------------------------------------------- + // SetupResults field tests + // ------------------------------------------------------------------------- + + [Fact] + public void SetupResults_AgentInstanceRegistered_DefaultsFalse() + { + var results = new SetupResults(); + results.AgentInstanceRegistered.Should().BeFalse(); + } + + [Fact] + public void SetupResults_AgentInstanceId_DefaultsNull() + { + var results = new SetupResults(); + results.AgentInstanceId.Should().BeNull(); + } + + [Fact] + public void SetupResults_CanSetAgentInstanceRegisteredAndId() + { + var results = new SetupResults(); + results.AgentInstanceRegistered = true; + results.AgentInstanceId = "test-instance-id-123"; + + results.AgentInstanceRegistered.Should().BeTrue(); + results.AgentInstanceId.Should().Be("test-instance-id-123"); + } + + // ------------------------------------------------------------------------- + // GrantAgentIdentityPermissionsAsync tests + // ------------------------------------------------------------------------- + + private static (SetupContext ctx, GraphApiService graph) BuildGrantTestContext() + { + var graph = Substitute.ForPartsOf(); + + // Prevent real HTTP calls: return null for existing-grant lookup in CreateOrUpdateOauth2PermissionGrantAsync. + graph.GraphGetAsync( + Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>()) + .Returns((System.Text.Json.JsonDocument?)null); + + var config = new Agent365Config + { + AiTeammate = false, + TenantId = "tenant-id", + AgentIdentityDisplayName = "Test Agent", + ClientAppId = "client-app-id", + Location = "eastus", + SubscriptionId = "sub-id", + AgenticAppId = "agentic-app-id", + }; + + var mockExecutor = BuildMockExecutor(); + var configService = Substitute.For(); + configService.SaveStateAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var ctx = new SetupContext( + config: config, + results: new SetupResults(), + logger: Substitute.For(), + configFile: new FileInfo("a365.config.json"), + generatedConfigPath: "a365.generated.config.json", + correlationId: "test-correlation-id", + skipInfrastructure: true, + skipRequirements: true, + cancellationToken: CancellationToken.None, + configService: configService, + executor: mockExecutor, + botConfigurator: Substitute.For(), + authValidator: Substitute.For( + NullLogger.Instance, mockExecutor), + platformDetector: Substitute.ForPartsOf( + Substitute.For>()), + graphApiService: graph, + blueprintService: Substitute.ForPartsOf( + Substitute.For>(), graph), + blueprintLookupService: Substitute.ForPartsOf( + Substitute.For>(), graph), + federatedCredentialService: Substitute.ForPartsOf( + Substitute.For>(), graph), + clientAppValidator: Substitute.For(), + loginHintResolver: () => Task.FromResult(null)); + + return (ctx, graph); + } + + private static List OneSpec() => + [new ResourcePermissionSpec("resource-app-id", "Test Resource", ["user_impersonation"], false)]; + + /// + /// When all SP lookups and grant POSTs succeed, no warnings are added to SetupResults. + /// + [Fact] + public async Task GrantAgentIdentityPermissions_HappyPath_NoWarningsAdded() + { + var (ctx, graph) = BuildGrantTestContext(); + + graph.GetCurrentUserObjectIdAsync(Arg.Any(), Arg.Any()) + .Returns("user-object-id"); + + graph.EnsureServicePrincipalForAppIdAsync( + Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns("sp-object-id"); + + graph.GraphPostWithResponseAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>()) + .Returns(new GraphApiService.GraphResponse { IsSuccess = true, Body = "{}" }); + + await NonDwBlueprintSetupOrchestrator.GrantAgentIdentityPermissionsAsync(ctx, OneSpec()); + + ctx.Results.HasWarnings.Should().BeFalse(because: "all grants succeeded — no warnings expected"); + } + + /// + /// When the agent identity SP cannot be resolved, no grant POSTs are made and the method + /// returns early without adding a warning to SetupResults (SP lookup failure is logged, not a Results entry). + /// + [Fact] + public async Task GrantAgentIdentityPermissions_AgentIdentitySpNotFound_NoGrantCallsMade() + { + var (ctx, graph) = BuildGrantTestContext(); + + graph.EnsureServicePrincipalForAppIdAsync( + Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns((string?)null); + + await NonDwBlueprintSetupOrchestrator.GrantAgentIdentityPermissionsAsync(ctx, OneSpec()); + + await graph.DidNotReceive().GraphPostWithResponseAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>()); + } + + /// + /// When a permission grant POST fails, anyFailed is set and a warning is added to + /// ctx.Results.Warnings so the setup summary reflects the partial failure. + /// + [Fact] + public async Task GrantAgentIdentityPermissions_GrantFails_AddsWarningToResults() + { + var (ctx, graph) = BuildGrantTestContext(); + + graph.EnsureServicePrincipalForAppIdAsync( + Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>(), Arg.Any()) + .Returns("sp-object-id"); + + graph.GraphPostWithResponseAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>()) + .Returns(new GraphApiService.GraphResponse { IsSuccess = false, Body = "Unauthorized" }); + + await NonDwBlueprintSetupOrchestrator.GrantAgentIdentityPermissionsAsync(ctx, OneSpec()); + + ctx.Results.HasWarnings.Should().BeTrue(because: "a grant failure must surface in setup results"); + ctx.Results.Warnings.Should().ContainSingle() + .Which.Should().Contain("Entra portal", + because: "the warning must tell the user where to manually grant permissions"); + } + + /// + /// When specs is empty the method returns immediately — no SP lookups or grant calls made. + /// + [Fact] + public async Task GrantAgentIdentityPermissions_EmptySpecs_NoCallsAndNoSideEffects() + { + var (ctx, graph) = BuildGrantTestContext(); + + await NonDwBlueprintSetupOrchestrator.GrantAgentIdentityPermissionsAsync(ctx, []); + + ctx.Results.HasWarnings.Should().BeFalse(); + await graph.DidNotReceive().EnsureServicePrincipalForAppIdAsync( + Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>(), Arg.Any()); + } +} 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 new file mode 100644 index 00000000..267d6c95 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwPublishCommandDryRunTests.cs @@ -0,0 +1,303 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Tests for PublishCommand non-DW dry-run behavior — Phase A. +/// Verifies correct template selection, field substitution preview, and that no files are written. +/// +[CollectionDefinition("NonDwPublishCommandDryRunTests", DisableParallelization = true)] +public class NonDwPublishCommandDryRunTestCollection { } + +[Collection("NonDwPublishCommandDryRunTests")] +public class NonDwPublishCommandDryRunTests : IDisposable +{ + private readonly ILogger _logger; + private readonly IConfigService _configService; + private readonly ManifestTemplateService _manifestTemplateService; + private readonly TextReader _originalConsoleIn = Console.In; + + public NonDwPublishCommandDryRunTests() + { + _logger = Substitute.For>(); + _configService = Substitute.For(); + _manifestTemplateService = Substitute.ForPartsOf( + Substitute.For>()); + + Console.SetIn(new StringReader("n\n\n")); + } + + public void Dispose() => Console.SetIn(_originalConsoleIn); + + private static Agent365Config BuildNonDwConfig( + string clientAppId = "11111111-1111-1111-1111-111111111111", + string webAppName = "webapp-myagent") => + new() + { + ClientAppId = clientAppId, + WebAppName = webAppName, + AiTeammate = false, + TenantId = "tenant-id", + SubscriptionId = "sub-id", + Location = "eastus", + AgentIdentityDisplayName = "My Agent", + DeploymentProjectPath = "./app" + }; + + [Fact] + public async Task Publish_NonDwDryRun_ViaFlag_ReturnsExitCode0() + { + // AiTeammate not set in config — driven by flag only + var config = new Agent365Config + { + ClientAppId = "11111111-1111-1111-1111-111111111111", + WebAppName = "webapp-myagent", + AiTeammate = null, + TenantId = "tenant-id", + SubscriptionId = "sub-id", + Location = "eastus", + AgentIdentityDisplayName = "My Agent", + DeploymentProjectPath = "./app" + }; + _configService.LoadAsync().Returns(config); + _configService.LoadAsync(Arg.Any()).Returns(config); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + var exitCode = await root.InvokeAsync("publish --dry-run --aiteammate false"); + + exitCode.Should().Be(0, "non-DW dry-run via flag should succeed"); + } + + [Fact] + public async Task Publish_NonDwDryRun_ViaConfigAiTeammate_ReturnsExitCode0() + { + var config = BuildNonDwConfig(); + _configService.LoadAsync().Returns(config); + _configService.LoadAsync(Arg.Any()).Returns(config); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + var exitCode = await root.InvokeAsync("publish --dry-run"); + + exitCode.Should().Be(0, "non-DW dry-run via config aiTeammate should succeed"); + } + +[Fact] + public async Task Publish_NonDwDryRun_LogsClientAppIdAsSourceOfTruth() + { + const string clientAppId = "aaaabbbb-cccc-dddd-eeee-ffffffffffff"; + var config = BuildNonDwConfig(clientAppId: clientAppId); + _configService.LoadAsync().Returns(config); + _configService.LoadAsync(Arg.Any()).Returns(config); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + await root.InvokeAsync("publish --dry-run"); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains(clientAppId)), + null, + Arg.Any>()); + } + + [Fact] + public async Task Publish_NonDwDryRun_LogsWebAppDomainInValidDomains() + { + var config = BuildNonDwConfig(webAppName: "webapp-contoso-prod"); + _configService.LoadAsync().Returns(config); + _configService.LoadAsync(Arg.Any()).Returns(config); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + await root.InvokeAsync("publish --dry-run"); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("webapp-contoso-prod.azurewebsites.net")), + null, + Arg.Any>()); + } + + [Fact] + public async Task Publish_NonDwDryRun_LogsZipContentsWithoutAgenticUserManifest() + { + var config = BuildNonDwConfig(); + _configService.LoadAsync().Returns(config); + _configService.LoadAsync(Arg.Any()).Returns(config); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + await root.InvokeAsync("publish --dry-run"); + + // Should mention color.png + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("color.png")), + null, + Arg.Any>()); + + // Must NOT mention agenticUserTemplateManifest.json + _logger.DidNotReceive().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("agenticUserTemplateManifest")), + null, + Arg.Any>()); + } + + [Fact] + public async Task Publish_NonDwWithoutDryRun_ReturnsExitCode1() + { + var config = BuildNonDwConfig(); + _configService.LoadAsync().Returns(config); + _configService.LoadAsync(Arg.Any()).Returns(config); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + var exitCode = await root.InvokeAsync("publish"); + + exitCode.Should().Be(1, "non-DW publish without --dry-run should return 1 until Phase B is implemented"); + } + + [Fact] + public async Task Publish_DigitalWorkerPath_IsUnaffectedByChanges() + { + // Verify DW path still requires blueprintId (no regression) + var config = new Agent365Config + { + AgentBlueprintId = null, + AiTeammate = true, + TenantId = "tenant-id", + ClientAppId = "client-app-id" + }; + _configService.LoadAsync().Returns(config); + _configService.LoadAsync(Arg.Any()).Returns(config); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + var exitCode = await root.InvokeAsync("publish"); + + exitCode.Should().Be(1, "DW path without blueprintId should still return 1"); + } + + [Fact] + public async Task Publish_BlueprintNonDwDryRun_ViaFlag_ReturnsExitCode0() + { + var config = new Agent365Config + { + AiTeammate = null, + TenantId = "tenant-id", + ClientAppId = "client-app-id", + AgentIdentityDisplayName = "My Agent" + }; + _configService.LoadAsync().Returns(config); + _configService.LoadAsync(Arg.Any()).Returns(config); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + var exitCode = await root.InvokeAsync("publish --dry-run --aiteammate false --use-blueprint"); + + exitCode.Should().Be(0, "blueprint non-DW dry-run via flags should succeed"); + } + + [Fact] + public async Task Publish_BlueprintNonDwDryRun_ViaConfig_ReturnsExitCode0() + { + var config = new Agent365Config + { + AiTeammate = false, + UseBlueprint = true, + TenantId = "tenant-id", + ClientAppId = "client-app-id", + AgentIdentityDisplayName = "My Agent" + }; + _configService.LoadAsync().Returns(config); + _configService.LoadAsync(Arg.Any()).Returns(config); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + var exitCode = await root.InvokeAsync("publish --dry-run"); + + exitCode.Should().Be(0, "blueprint non-DW dry-run via config should succeed"); + } + + [Fact] + public async Task Publish_BlueprintNonDwDryRun_LogsBlueprintId() + { + 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, + AgentIdentityDisplayName = "My Agent" + }; + _configService.LoadAsync().Returns(config); + _configService.LoadAsync(Arg.Any()).Returns(config); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + await root.InvokeAsync("publish --dry-run"); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains(blueprintId)), + null, + Arg.Any>()); + } + + [Fact] + public async Task Publish_BlueprintNonDwDryRun_DoesNotLogManifestOrZip() + { + var config = new Agent365Config + { + AiTeammate = false, + UseBlueprint = true, + TenantId = "tenant-id", + ClientAppId = "client-app-id", + AgentIdentityDisplayName = "My Agent" + }; + _configService.LoadAsync().Returns(config); + _configService.LoadAsync(Arg.Any()).Returns(config); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + await root.InvokeAsync("publish --dry-run"); + + _logger.DidNotReceive().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("manifest.nondw.json")), + null, + Arg.Any>()); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwSetupOrchestratorDryRunTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwSetupOrchestratorDryRunTests.cs new file mode 100644 index 00000000..a6eeb615 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwSetupOrchestratorDryRunTests.cs @@ -0,0 +1,285 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Tests for NonDwSetupOrchestrator.PrintDryRunPlan — Phase A dry-run output. +/// Verifies that the plan is printed with correct values from config and no Azure API calls are made. +/// +public class NonDwSetupOrchestratorDryRunTests +{ + private readonly ILogger _logger = Substitute.For(); + + private static Agent365Config BuildConfig( + string displayName = "My Agent", + string resourceGroup = "rg-test", + string webAppName = "webapp-myagent", + string appServicePlanName = "asp-myagent", + string appServicePlanSku = "B1", + string? messagingEndpoint = null, + bool needDeployment = true, + bool needAzureOpenAI = false, + string? azureOpenAIName = null, + string? azureOpenAILocation = null, + string? azureOpenAIModelDeploymentName = null) => + new() + { + AgentIdentityDisplayName = displayName, + ResourceGroup = resourceGroup, + WebAppName = webAppName, + AppServicePlanName = appServicePlanName, + AppServicePlanSku = appServicePlanSku, + MessagingEndpoint = messagingEndpoint ?? string.Empty, + NeedDeployment = needDeployment, + NeedAzureOpenAI = needAzureOpenAI, + AzureOpenAIName = azureOpenAIName, + AzureOpenAILocation = azureOpenAILocation, + AzureOpenAIModelDeploymentName = azureOpenAIModelDeploymentName, + AiTeammate = false, + TenantId = "tenant-id", + SubscriptionId = "sub-id", + ClientAppId = "client-app-id", + Location = "eastus", + DeploymentProjectPath = "./app" + }; + + [Fact] + public void PrintDryRunPlan_LogsHeader() + { + var config = BuildConfig(); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("dry run") && o.ToString()!.Contains("no changes")), + null, + Arg.Any>()); + } + + [Fact] + public void PrintDryRunPlan_IncludesAgentDisplayName() + { + var config = BuildConfig(displayName: "Contoso Sales Agent"); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Contoso Sales Agent")), + null, + Arg.Any>()); + } + + [Fact] + public void PrintDryRunPlan_IncludesResourceGroupName() + { + var config = BuildConfig(resourceGroup: "rg-contoso-prod"); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("rg-contoso-prod")), + null, + Arg.Any>()); + } + + [Fact] + public void PrintDryRunPlan_IncludesTeamsClientIds() + { + var config = BuildConfig(); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains(NonDwSetupOrchestrator.TeamsDesktopMobileClientId)), + null, + Arg.Any>()); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains(NonDwSetupOrchestrator.TeamsWebClientId)), + null, + Arg.Any>()); + } + + [Fact] + public void PrintDryRunPlan_IncludesGraphPermissions() + { + var config = BuildConfig(); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + // At least User.Read should appear + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("User.Read")), + null, + Arg.Any>()); + } + + [Fact] + public void PrintDryRunPlan_IncludesAgent365ToolsPermissions() + { + var config = BuildConfig(); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("McpServers.Mail.All")), + null, + Arg.Any>()); + } + + [Fact] + public void PrintDryRunPlan_IncludesOboConnectionName() + { + var config = BuildConfig(); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains(NonDwSetupOrchestrator.OboConnectionName)), + null, + Arg.Any>()); + } + + [Fact] + public void PrintDryRunPlan_WithNeedDeployment_IncludesWebAppName() + { + var config = BuildConfig(webAppName: "webapp-contoso", needDeployment: true); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("webapp-contoso")), + null, + Arg.Any>()); + } + + [Fact] + public void PrintDryRunPlan_WithNeedDeploymentFalse_SkipsInfrastructure() + { + var config = BuildConfig( + webAppName: "webapp-contoso", + needDeployment: false, + messagingEndpoint: "https://my-bot.example.com/api/messages"); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Skip") && o.ToString()!.Contains("Deployment")), + null, + Arg.Any>()); + } + + [Fact] + public void PrintDryRunPlan_WithNeedAzureOpenAI_IncludesAoaiResource() + { + var config = BuildConfig( + needAzureOpenAI: true, + azureOpenAIName: "aoai-contoso", + azureOpenAILocation: "swedencentral", + azureOpenAIModelDeploymentName: "gpt-4.1"); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("aoai-contoso")), + null, + Arg.Any>()); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("gpt-4.1")), + null, + Arg.Any>()); + } + + [Fact] + public void PrintDryRunPlan_WithoutNeedAzureOpenAI_DoesNotIncludeAoaiLine() + { + var config = BuildConfig(needAzureOpenAI: false, azureOpenAIName: "aoai-should-not-appear"); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + _logger.DidNotReceive().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("aoai-should-not-appear")), + null, + Arg.Any>()); + } + + [Fact] + public void PrintDryRunPlan_DerivesMessagingEndpointFromWebAppName_WhenEndpointNotSet() + { + var config = BuildConfig(webAppName: "webapp-mybot", messagingEndpoint: null, needDeployment: true); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("webapp-mybot.azurewebsites.net/api/messages")), + null, + Arg.Any>()); + } + + [Fact] + public void PrintDryRunPlan_UsesExplicitMessagingEndpoint_WhenSet() + { + var config = BuildConfig(messagingEndpoint: "https://custom.endpoint.example.com/api/messages"); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("custom.endpoint.example.com")), + null, + Arg.Any>()); + } + + [Fact] + public void PrintDryRunPlan_IncludesRunWithoutDryRunInstruction() + { + var config = BuildConfig(); + + NonDwSetupOrchestrator.PrintDryRunPlan(config, _logger); + + _logger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("--dry-run")), + null, + Arg.Any>()); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs index 473c19e3..3239a67b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs @@ -462,8 +462,8 @@ public async Task ConfigureMcpPermissionsAsync_WithMissingManifest_ShouldHandleG config, false); - result.Should().BeTrue( - because: "McpServersMetadata.Read.All is always included even when the ToolingManifest is missing, so the method proceeds to configure permissions and returns true (pending admin consent)"); + result.Should().BeFalse( + because: "MCP has no Graph scopes, so the consent check runs against the mocked Graph service which has no oauth2PermissionGrants — grants are not present and admin action is required, so the method correctly returns false"); } [Fact] diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs index d5b4e369..dc99dcb6 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs @@ -107,8 +107,9 @@ public async Task SetupAllCommand_DryRun_ValidConfig_OnlyValidatesConfig() // Assert Assert.Equal(0, result); - // Dry-run mode does not load config or call Azure/Bot services - it just displays what would be done - await _mockConfigService.DidNotReceiveWithAnyArgs().LoadAsync(Arg.Any(), Arg.Any()); + // Dry-run mode loads config to display the plan (real values, not placeholders) + await _mockConfigService.ReceivedWithAnyArgs(1).LoadAsync(Arg.Any(), Arg.Any()); + // ...but must not call any Azure or Bot services await _mockBotConfigurator.DidNotReceiveWithAnyArgs().CreateEndpointWithAgentBlueprintAsync(default!, default!, default!, default!, default!); } @@ -116,33 +117,33 @@ public async Task SetupAllCommand_DryRun_ValidConfig_OnlyValidatesConfig() public async Task SetupAllCommand_SkipInfrastructure_SkipsInfrastructureStep() { // Arrange - var config = new Agent365Config - { - TenantId = "tenant", - SubscriptionId = "sub", - ResourceGroup = "rg", - Location = "eastus", - AppServicePlanName = "plan", - WebAppName = "web", - AgentIdentityDisplayName = "agent", + var config = new Agent365Config + { + TenantId = "tenant", + SubscriptionId = "sub", + ResourceGroup = "rg", + Location = "eastus", + AppServicePlanName = "plan", + WebAppName = "web", + AgentIdentityDisplayName = "agent", DeploymentProjectPath = ".", AgentBlueprintId = "blueprint-app-id", AgentBlueprintDisplayName = "TestBlueprint", Environment = "prod" }; - + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); - + var command = SetupCommand.CreateCommand( - _mockLogger, - _mockConfigService, - _mockExecutor, - _mockDeploymentService, + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockDeploymentService, _mockBotConfigurator, _mockAuthValidator, _mockPlatformDetector, _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider); - + var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -151,9 +152,52 @@ public async Task SetupAllCommand_SkipInfrastructure_SkipsInfrastructureStep() // Assert Assert.Equal(0, result); - - // Dry-run mode does not load config - it just displays what would be done (with infrastructure skipped) + + // Dry-run mode loads config to display the plan (real values, not placeholders) + await _mockConfigService.ReceivedWithAnyArgs(1).LoadAsync(Arg.Any(), Arg.Any()); + // ...but must not call any Azure or Bot services + await _mockBotConfigurator.DidNotReceiveWithAnyArgs().CreateEndpointWithAgentBlueprintAsync(default!, default!, default!, default!, default!); + } + + [Fact] + public async Task SetupAllCommand_WithAgentName_DryRun_SucceedsWithoutConfigFile() + { + // Arrange — no config file stub needed; --agent-name bootstrap path skips LoadAsync + // and also skips the Graph lookup (dry-run only detects tenant) + _mockExecutor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.StartsWith("account show", StringComparison.OrdinalIgnoreCase)), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new Microsoft.Agents.A365.DevTools.Cli.Services.CommandResult + { + ExitCode = 0, + StandardOutput = "{\"tenantId\":\"dry-run-tenant-id\"}", + StandardError = string.Empty + })); + + var command = SetupCommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockDeploymentService, + _mockBotConfigurator, + _mockAuthValidator, + _mockPlatformDetector, + _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider); + + var parser = new CommandLineBuilder(command).Build(); + var testConsole = new TestConsole(); + + // Act + var result = await parser.InvokeAsync("all --agent-name MyAgent --dry-run", testConsole); + + // Assert + Assert.Equal(0, result); + + // Bootstrap dry-run must not load the config file or call any Azure services await _mockConfigService.DidNotReceiveWithAnyArgs().LoadAsync(Arg.Any(), Arg.Any()); + await _mockGraphApiService.DidNotReceiveWithAnyArgs().FindApplicationByDisplayNameAsync(default!, default!, default); + await _mockBotConfigurator.DidNotReceiveWithAnyArgs().CreateEndpointWithAgentBlueprintAsync(default!, default!, default!, default!, default!); } [Fact] @@ -325,20 +369,21 @@ public async Task BlueprintSubcommand_DryRun_CompletesSuccessfully() public async Task RequirementsSubcommand_ValidConfig_CompletesSuccessfully() { // Arrange - var config = new Agent365Config - { - TenantId = "tenant", - SubscriptionId = "sub", - ResourceGroup = "rg", - Location = "eastus", - AppServicePlanName = "plan", - WebAppName = "web", - AgentIdentityDisplayName = "agent", + var config = new Agent365Config + { + TenantId = "tenant", + SubscriptionId = "sub", + ResourceGroup = "rg", + Location = "eastus", + AppServicePlanName = "plan", + WebAppName = "web", + AgentIdentityDisplayName = "agent", DeploymentProjectPath = "." }; - + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); + // requirementChecksOverride: [] — bypass real pwsh/az processes in unit tests var command = SetupCommand.CreateCommand( _mockLogger, _mockConfigService, @@ -349,7 +394,8 @@ public async Task RequirementsSubcommand_ValidConfig_CompletesSuccessfully() _mockPlatformDetector, _mockGraphApiService, _mockBlueprintService, - _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider); + _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider, + requirementChecksOverride: []); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -368,20 +414,21 @@ public async Task RequirementsSubcommand_ValidConfig_CompletesSuccessfully() public async Task RequirementsSubcommand_WithCategoryFilter_RunsFilteredChecks() { // Arrange - var config = new Agent365Config - { - TenantId = "tenant", - SubscriptionId = "sub", - ResourceGroup = "rg", - Location = "eastus", - AppServicePlanName = "plan", - WebAppName = "web", - AgentIdentityDisplayName = "agent", + var config = new Agent365Config + { + TenantId = "tenant", + SubscriptionId = "sub", + ResourceGroup = "rg", + Location = "eastus", + AppServicePlanName = "plan", + WebAppName = "web", + AgentIdentityDisplayName = "agent", DeploymentProjectPath = "." }; - + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(config)); + // requirementChecksOverride: [] — bypass real pwsh/az processes in unit tests var command = SetupCommand.CreateCommand( _mockLogger, _mockConfigService, @@ -392,7 +439,8 @@ public async Task RequirementsSubcommand_WithCategoryFilter_RunsFilteredChecks() _mockPlatformDetector, _mockGraphApiService, _mockBlueprintService, - _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider); + _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider, + requirementChecksOverride: []); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs index a201da8d..c051ecea 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs @@ -129,10 +129,11 @@ public void MissingAdminConsent_CreatesExceptionWithCorrectProperties() exception.Should().NotBeNull(); exception.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); exception.IssueDescription.Should().Be("Admin consent not granted for client app"); - exception.ErrorDetails.Should().HaveCount(2); + exception.ErrorDetails.Should().HaveCount(3, because: "MissingAdminConsent includes: (1) permissions-configured check, (2) per-user consent warning, (3) Global Administrator requirement"); exception.ErrorDetails[0].Should().Contain("permissions are configured"); - exception.ErrorDetails[1].Should().Contain("Global Administrator"); - exception.MitigationSteps.Should().HaveCount(6); + exception.ErrorDetails[1].Should().Contain("per-user consent"); + exception.ErrorDetails[2].Should().Contain("Global Administrator"); + exception.MitigationSteps.Should().HaveCount(4, because: "MissingAdminConsent provides 4 mitigation steps: run setup requirements, run setup admin, share consent URL, and contact IT admin"); exception.Context.Should().ContainKey("clientAppId"); exception.Context["clientAppId"].Should().Be(TestClientAppId); } @@ -145,11 +146,27 @@ public void MissingAdminConsent_IncludesConsentGrantInstructions() // Assert exception.MitigationSteps.Should().Contain(s => s.Contains("Grant admin consent")); - exception.MitigationSteps.Should().Contain(s => s.Contains("Confirm the consent dialog")); exception.MitigationSteps.Should().Contain(s => s.Contains(ConfigConstants.Agent365CliDocumentationUrl)); } + [Fact] + public void BuildAdminConsentUrl_EncodesRedirectUri() + { + // Act + var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(TestClientAppId, TestTenantId); + + // Assert + consentUrl.Should().NotBeNull(); + consentUrl.Should().Contain($"client_id={TestClientAppId}", because: "the client ID must be preserved in the admin consent URL query string"); + consentUrl.Should().Contain( + $"redirect_uri={Uri.EscapeDataString("https://login.microsoftonline.com/common/oauth2/nativeclient")}", + because: "redirect_uri is a URL-valued query parameter and must be encoded so the consent link remains valid when copied through shells, logs, and browsers"); + consentUrl.Should().NotContain( + "&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient", + because: "an unescaped redirect URI contains reserved characters that can corrupt the admin consent query string"); + } + #endregion #region ValidationFailed Tests diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ProjectSettingsSyncHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ProjectSettingsSyncHelperTests.cs index 44845143..d79a2841 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ProjectSettingsSyncHelperTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ProjectSettingsSyncHelperTests.cs @@ -562,46 +562,177 @@ public async Task ExecuteAsync_DotNet_UnprotectedSecret_WritesAsIs() Assert.Equal(plaintextSecret, clientSecret); } + // ------------------------------------------------------------------------- + // Agent365Observability section tests + // ------------------------------------------------------------------------- + + /// + /// Non-DW flow (AgenticAppId set): AgentId must use the Agent Identity, not the Blueprint. + /// AgentName, AgentDescription, TenantId, ClientId (blueprint), and ClientSecret are written. + /// [Fact] - public async Task ExecuteAsync_DotNet_WritesAgent365ObservabilitySection() + public async Task ExecuteAsync_DotNet_WritesAgent365Observability_NonDw() { - // Arrange - var projectDir = Path.Combine(_tempRoot, "dotnet_obs"); + var projectDir = Path.Combine(_tempRoot, "dotnet_obs_nondw"); Directory.CreateDirectory(projectDir); - WriteFile(projectDir, "MyAgent.csproj", ""); var appsettingsPath = WriteFile(projectDir, "appsettings.json", "{}"); - var genPath = WriteFile(_tempRoot, "a365.generated.config.json", "{}"); - var cfgPath = WriteFile(_tempRoot, "a365.config.json", "{}"); + var genPath = WriteFile(_tempRoot, "a365.generated.obs_nondw.json", "{}"); + var cfgPath = WriteFile(_tempRoot, "a365.obs_nondw.config.json", "{}"); + var cfg = new Agent365Config { DeploymentProjectPath = projectDir, - TenantId = "5369a35c-46a5-4677-8ff9-2e65587654e7", - AgentBlueprintId = "73cfe0a9-87bb-4cfd-bfe1-4309c487d56c", - AgentBlueprintClientSecret = "secret" + TenantId = "tenant-obs-id", + AgenticAppId = "agent-identity-app-id", + AgentBlueprintId = "blueprint-app-id", + AgentIdentityDisplayName = "My Agent Identity", + AgentDescription = "An agent for testing", + AgentBlueprintClientSecret = "obs-secret", + AgentBlueprintClientSecretProtected = false }; - var configService = MockConfigService(cfg).Object; - var platformDetector = CreatePlatformDetector(); - var logger = CreateLogger(); + await ProjectSettingsSyncHelper.ExecuteAsync(cfgPath, genPath, + MockConfigService(cfg).Object, CreatePlatformDetector(), CreateLogger()); + + var obs = ReadJson(appsettingsPath)["Agent365Observability"]!.AsObject(); + // non-DW must use Agent Identity app ID, not Blueprint + Assert.Equal("agent-identity-app-id", obs["AgentId"]!.GetValue()); + Assert.Equal("My Agent Identity", obs["AgentName"]!.GetValue()); + Assert.Equal("An agent for testing", obs["AgentDescription"]!.GetValue()); + Assert.Equal("tenant-obs-id", obs["TenantId"]!.GetValue()); + Assert.Equal("blueprint-app-id", obs["AgentBlueprintId"]!.GetValue()); + Assert.Equal("blueprint-app-id", obs["ClientId"]!.GetValue()); + Assert.Equal("obs-secret", obs["ClientSecret"]!.GetValue()); + } - // Act - await ProjectSettingsSyncHelper.ExecuteAsync(cfgPath, genPath, configService, platformDetector, logger); + /// + /// DW flow (AgenticAppId null/empty): AgentId falls back to the Blueprint app ID. + /// + [Fact] + public async Task ExecuteAsync_DotNet_WritesAgent365Observability_Dw() + { + var projectDir = Path.Combine(_tempRoot, "dotnet_obs_dw"); + Directory.CreateDirectory(projectDir); + WriteFile(projectDir, "MyAgent.csproj", ""); + var appsettingsPath = WriteFile(projectDir, "appsettings.json", "{}"); - // Assert - var j = ReadJson(appsettingsPath); + var genPath = WriteFile(_tempRoot, "a365.generated.obs_dw.json", "{}"); + var cfgPath = WriteFile(_tempRoot, "a365.obs_dw.config.json", "{}"); - // EnableAgent365Exporter written at root level, defaulting to false - Assert.False(j["EnableAgent365Exporter"]!.GetValue()); + var cfg = new Agent365Config + { + DeploymentProjectPath = projectDir, + TenantId = "tenant-dw-id", + AgenticAppId = null, // DW: no agent identity + AgentBlueprintId = "blueprint-dw-id", + AgentBlueprintClientSecret = "dw-secret", + AgentBlueprintClientSecretProtected = false + }; + + await ProjectSettingsSyncHelper.ExecuteAsync(cfgPath, genPath, + MockConfigService(cfg).Object, CreatePlatformDetector(), CreateLogger()); - // Agent365Observability section present with correct values + var j = ReadJson(appsettingsPath); var obs = j["Agent365Observability"]!.AsObject(); - Assert.Null(obs["AgentId"]); - Assert.Equal(cfg.AgentBlueprintId, obs["AgentBlueprintId"]!.GetValue()); - Assert.Equal(cfg.TenantId, obs["TenantId"]!.GetValue()); - Assert.Equal("", obs["AgentName"]!.GetValue()); - Assert.Equal("", obs["AgentDescription"]!.GetValue()); + // DW must fall back to Blueprint app ID when AgenticAppId is absent + Assert.Equal("blueprint-dw-id", obs["AgentId"]!.GetValue()); + Assert.Equal("tenant-dw-id", obs["TenantId"]!.GetValue()); + Assert.Equal("blueprint-dw-id", obs["AgentBlueprintId"]!.GetValue()); + Assert.Equal("blueprint-dw-id", obs["ClientId"]!.GetValue()); + // EnableAgent365Exporter is written to false by default + Assert.False(j["EnableAgent365Exporter"]!.GetValue()); + } + + /// + /// Python .env: Agent365Observability keys use UPPER_SNAKE_CASE with double-underscores. + /// + [Fact] + public async Task ExecuteAsync_Python_WritesAgent365Observability() + { + var projectDir = Path.Combine(_tempRoot, "py_obs"); + Directory.CreateDirectory(projectDir); + WriteFile(projectDir, "pyproject.toml", "[tool.poetry]"); + var envPath = WriteFile(projectDir, ".env", ""); + + var genPath = WriteFile(_tempRoot, "a365.generated.py_obs.json", "{}"); + var cfgPath = WriteFile(_tempRoot, "a365.py_obs.config.json", "{}"); + + var cfg = new Agent365Config + { + DeploymentProjectPath = projectDir, + TenantId = "tenant-py-id", + AgenticAppId = "agent-py-id", + AgentBlueprintId = "blueprint-py-id", + AgentIdentityDisplayName = "Py-Agent", + AgentDescription = "Python-test-agent", + AgentBlueprintClientSecret = "py-secret", + AgentBlueprintClientSecretProtected = false + }; + + await ProjectSettingsSyncHelper.ExecuteAsync(cfgPath, genPath, + MockConfigService(cfg).Object, CreatePlatformDetector(), CreateLogger()); + + var lines = File.ReadAllLines(envPath); + string Val(string key) => lines + .First(l => l.StartsWith(key + "=", StringComparison.OrdinalIgnoreCase)) + .Split('=', 2)[1]; + + // non-DW uses Agent Identity app ID + Assert.Equal("agent-py-id", Val("AGENT365OBSERVABILITY__AGENTID")); + Assert.Equal("Py-Agent", Val("AGENT365OBSERVABILITY__AGENTNAME")); + Assert.Equal("Python-test-agent", Val("AGENT365OBSERVABILITY__AGENTDESCRIPTION")); + Assert.Equal("tenant-py-id", Val("AGENT365OBSERVABILITY__TENANTID")); + Assert.Equal("blueprint-py-id", Val("AGENT365OBSERVABILITY__AGENTBLUEPRINTID")); + Assert.Equal("blueprint-py-id", Val("AGENT365OBSERVABILITY__CLIENTID")); + Assert.Equal("py-secret", Val("AGENT365OBSERVABILITY__CLIENTSECRET")); + Assert.Contains(lines, l => l.StartsWith("ENABLE_A365_OBSERVABILITY_EXPORTER=", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Node .env: Agent365Observability keys use camelCase with double-underscores. + /// + [Fact] + public async Task ExecuteAsync_Node_WritesAgent365Observability() + { + var projectDir = Path.Combine(_tempRoot, "node_obs"); + Directory.CreateDirectory(projectDir); + WriteFile(projectDir, "package.json", "{ \"name\": \"sample\" }"); + var envPath = WriteFile(projectDir, ".env", ""); + + var genPath = WriteFile(_tempRoot, "a365.generated.node_obs.json", "{}"); + var cfgPath = WriteFile(_tempRoot, "a365.node_obs.config.json", "{}"); + + var cfg = new Agent365Config + { + DeploymentProjectPath = projectDir, + TenantId = "tenant-node-id", + AgenticAppId = "agent-node-id", + AgentBlueprintId = "blueprint-node-id", + AgentIdentityDisplayName = "Node Agent", + AgentDescription = "Node test agent", + AgentBlueprintClientSecret = "node-secret", + AgentBlueprintClientSecretProtected = false + }; + + await ProjectSettingsSyncHelper.ExecuteAsync(cfgPath, genPath, + MockConfigService(cfg).Object, CreatePlatformDetector(), CreateLogger()); + + var lines = File.ReadAllLines(envPath); + string Val(string key) => lines + .First(l => l.StartsWith(key + "=", StringComparison.OrdinalIgnoreCase)) + .Split('=', 2)[1]; + + // non-DW uses Agent Identity app ID + Assert.Equal("agent-node-id", Val("agent365Observability__agentId")); + Assert.Equal("Node Agent", Val("agent365Observability__agentName")); + Assert.Equal("Node test agent", Val("agent365Observability__agentDescription")); + Assert.Equal("tenant-node-id", Val("agent365Observability__tenantId")); + Assert.Equal("blueprint-node-id", Val("agent365Observability__agentBlueprintId")); + Assert.Equal("blueprint-node-id", Val("agent365Observability__clientId")); + Assert.Equal("node-secret", Val("agent365Observability__clientSecret")); + Assert.Contains(lines, l => l.StartsWith("ENABLE_A365_OBSERVABILITY_EXPORTER=", StringComparison.OrdinalIgnoreCase)); } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersBootstrapTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersBootstrapTests.cs new file mode 100644 index 00000000..b90a8dc6 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersBootstrapTests.cs @@ -0,0 +1,348 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Helpers; + +/// +/// Unit tests for the SetupHelpers bootstrap helper methods: +/// BuildConfiguredPermissionSpecsAsync, ResolveBootstrapTenantIdAsync, +/// ResolveBootstrapClientAppIdAsync, and GetJsonString. +/// +public class SetupHelpersBootstrapTests : IDisposable +{ + private readonly string _tempDir; + private readonly CommandExecutor _mockExecutor; + private readonly GraphApiService _mockGraph; + + public SetupHelpersBootstrapTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDir); + + var execLogger = Substitute.For>(); + _mockExecutor = Substitute.For(execLogger); + + _mockGraph = Substitute.ForPartsOf(); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { /* best-effort */ } + } + + // ── BuildConfiguredPermissionSpecsAsync ─────────────────────────────────── + + [Fact] + public async Task BuildConfiguredPermissionSpecsAsync_NoManifest_IncludesGraphAndFixedSpecs() + { + // Arrange: no ToolingManifest.json in tempDir — manifest read falls back to empty scopes + var config = new Agent365Config { DeploymentProjectPath = _tempDir }; + + // Act + var specs = await SetupHelpers.BuildConfiguredPermissionSpecsAsync(config, setInheritable: true); + + // Assert: Graph spec is always present + specs.Should().Contain(s => s.ResourceAppId == AuthenticationConstants.MicrosoftGraphResourceAppId, + because: "Microsoft Graph is always included in the DW permission spec list"); + + // Assert: fixed platform APIs (Messaging Bot, Observability, Power Platform) + specs.Should().Contain(s => s.ResourceAppId == ConfigConstants.MessagingBotApiAppId, + because: "Messaging Bot API is a fixed DW permission"); + specs.Should().Contain(s => s.ResourceAppId == ConfigConstants.ObservabilityApiAppId, + because: "Observability API is a fixed DW permission"); + specs.Should().Contain(s => s.ResourceAppId == PowerPlatformConstants.PowerPlatformApiResourceAppId, + because: "Power Platform API is a fixed DW permission"); + + // Assert: Graph spec carries the default agent application scopes + var graphSpec = specs.First(s => s.ResourceAppId == AuthenticationConstants.MicrosoftGraphResourceAppId); + graphSpec.Scopes.Should().NotBeEmpty( + because: "AgentApplicationScopes always includes at least the default set of delegated Graph scopes"); + } + + [Fact] + public async Task BuildConfiguredPermissionSpecsAsync_WithValidCustomPermission_IncludesCustomSpec() + { + // Arrange + var config = new Agent365Config + { + DeploymentProjectPath = _tempDir, + CustomBlueprintPermissions = new List + { + new() { ResourceAppId = "a1b2c3d4-0000-0000-0000-000000000000", ResourceName = "My API", Scopes = new List { "custom.scope" } } + } + }; + + // Act + var specs = await SetupHelpers.BuildConfiguredPermissionSpecsAsync(config, setInheritable: false); + + // Assert + specs.Should().Contain(s => s.ResourceAppId == "a1b2c3d4-0000-0000-0000-000000000000" && s.Scopes.Contains("custom.scope"), + because: "valid custom permissions must be appended to the spec list"); + + // Assert: no duplicates for the custom permission + specs.Count(s => s.ResourceAppId == "a1b2c3d4-0000-0000-0000-000000000000").Should().Be(1, + because: "each custom permission must appear exactly once in the spec list"); + } + + [Fact] + public async Task BuildConfiguredPermissionSpecsAsync_WithInvalidCustomPermission_ExcludesIt() + { + // Arrange: custom permission with empty ResourceAppId is invalid + var config = new Agent365Config + { + DeploymentProjectPath = _tempDir, + CustomBlueprintPermissions = new List + { + new() { ResourceAppId = string.Empty, ResourceName = "Bad Perm", Scopes = new List { "scope" } } + } + }; + + // Act + var specs = await SetupHelpers.BuildConfiguredPermissionSpecsAsync(config, setInheritable: false); + + // Assert + specs.Should().NotContain(s => string.IsNullOrEmpty(s.ResourceAppId), + because: "permissions with an empty ResourceAppId fail validation and must be excluded"); + } + + [Fact] + public async Task BuildConfiguredPermissionSpecsAsync_WithPreComputedScopes_DoesNotReadManifest() + { + // Arrange: point DeploymentProjectPath at a non-existent directory so any attempt to + // read the manifest from disk would return empty scopes. Provide a pre-computed + // scopesByAudience with a custom audience entry — if the method reads the manifest + // instead of using the provided dict, the audience entry will be absent. + var config = new Agent365Config + { + DeploymentProjectPath = Path.Combine(_tempDir, "nonexistent") + }; + var precomputed = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "injected-audience-app-id", new[] { "Injected.Scope" } } + }; + + // Act + var specs = await SetupHelpers.BuildConfiguredPermissionSpecsAsync(config, setInheritable: true, precomputed); + + // Assert: the injected audience must appear in the result + specs.Should().Contain(s => s.ResourceAppId == "injected-audience-app-id" && s.Scopes.Contains("Injected.Scope"), + because: "when scopesByAudience is supplied the method must use it instead of reading the manifest from disk"); + } + + // ── ResolveBootstrapTenantIdAsync ───────────────────────────────────────── + + [Fact] + public async Task ResolveBootstrapTenantIdAsync_WhenFlagProvided_ReturnsFlagWithoutCallingExecutor() + { + // Arrange + const string tenantIdFlag = "explicit-tenant-id"; + var logger = NullLogger.Instance; + + // Act + var result = await SetupHelpers.ResolveBootstrapTenantIdAsync(tenantIdFlag, _mockExecutor, logger); + + // Assert + result.Should().Be(tenantIdFlag, because: "an explicit --tenant-id flag bypasses az account show"); + + await _mockExecutor.DidNotReceive().ExecuteAsync( + Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ResolveBootstrapTenantIdAsync_WhenNoFlag_DetectsFromAzAccountShow() + { + // Arrange + const string expectedTenantId = "detected-tenant-id"; + var logger = NullLogger.Instance; + + // TenantDetectionHelper calls: az account show --query tenantId -o tsv + // which returns the raw tenant ID string (not JSON) as StandardOutput. + _mockExecutor.ExecuteAsync( + Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new CommandResult + { + ExitCode = 0, + StandardOutput = expectedTenantId, + StandardError = string.Empty + })); + + // Act + var result = await SetupHelpers.ResolveBootstrapTenantIdAsync(null, _mockExecutor, logger); + + // Assert + result.Should().Be(expectedTenantId, + because: "when no flag is provided the tenant is detected from az account show output"); + } + + [Fact] + public async Task ResolveBootstrapTenantIdAsync_WhenNoFlag_AndExecutorFails_ReturnsNull() + { + // Arrange + var logger = NullLogger.Instance; + + _mockExecutor.ExecuteAsync( + Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new CommandResult + { + ExitCode = 1, + StandardOutput = string.Empty, + StandardError = "az: command not found" + })); + + // Act + var result = await SetupHelpers.ResolveBootstrapTenantIdAsync(null, _mockExecutor, logger); + + // Assert + result.Should().BeNull(because: "a failed az account show must return null, not throw"); + } + + // ── ResolveBootstrapClientAppIdAsync ────────────────────────────────────── + + [Fact] + public async Task ResolveBootstrapClientAppIdAsync_WhenGraphServiceIsNull_ReturnsNull() + { + // Act + var result = await SetupHelpers.ResolveBootstrapClientAppIdAsync( + "tenant-id", graphApiService: null, NullLogger.Instance, CancellationToken.None); + + // Assert + result.Should().BeNull(because: "without a GraphApiService there is no way to resolve the client app ID"); + } + + [Fact] + public async Task ResolveBootstrapClientAppIdAsync_WhenEntraLookupSucceeds_ReturnsClientAppId() + { + // Arrange + const string clientAppId = "resolved-client-app-id"; + _mockGraph.FindApplicationByDisplayNameAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(clientAppId)); + + // Act + var result = await SetupHelpers.ResolveBootstrapClientAppIdAsync( + "tenant-id", _mockGraph, NullLogger.Instance, CancellationToken.None, + preferLocalConfig: false); + + // Assert + result.Should().Be(clientAppId, + because: "when Entra lookup succeeds the resolved app ID is returned"); + } + + [Fact] + public async Task ResolveBootstrapClientAppIdAsync_DoesNotMutateGraphServiceCustomClientAppId() + { + // Arrange: the side effect (graphApiService.CustomClientAppId = ...) was removed from the + // helper. Callers are responsible for setting CustomClientAppId after receiving the result. + const string clientAppId = "some-app-id"; + _mockGraph.FindApplicationByDisplayNameAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(clientAppId)); + + var idBefore = _mockGraph.CustomClientAppId; + + // Act + await SetupHelpers.ResolveBootstrapClientAppIdAsync( + "tenant-id", _mockGraph, NullLogger.Instance, CancellationToken.None); + + // Assert + _mockGraph.CustomClientAppId.Should().Be(idBefore, + because: "ResolveBootstrapClientAppIdAsync must not mutate graphApiService.CustomClientAppId — the caller owns that assignment"); + } + + [Fact] + public async Task ResolveBootstrapClientAppIdAsync_WhenPreferLocalConfig_AndTenantMatches_UsesLocalConfig() + { + // Arrange: write an a365.config.json whose tenantId matches + const string tenantId = "matching-tenant"; + const string configClientAppId = "config-client-app-id"; + var configJson = $"{{\"tenantId\":\"{tenantId}\",\"clientAppId\":\"{configClientAppId}\"}}"; + var configPath = Path.Combine(_tempDir, ConfigConstants.DefaultConfigFileName); + await File.WriteAllTextAsync(configPath, configJson); + + // Save and restore CWD to isolate the test + var originalCwd = Environment.CurrentDirectory; + Environment.CurrentDirectory = _tempDir; + try + { + // Act + var result = await SetupHelpers.ResolveBootstrapClientAppIdAsync( + tenantId, _mockGraph, NullLogger.Instance, CancellationToken.None, + preferLocalConfig: true); + + // Assert + result.Should().Be(configClientAppId, + because: "when preferLocalConfig=true and the local config tenant matches, the config value is used"); + + // Entra lookup must not be called when the local config provides the value + await _mockGraph.DidNotReceive().FindApplicationByDisplayNameAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + finally + { + Environment.CurrentDirectory = originalCwd; + } + } + + [Fact] + public async Task ResolveBootstrapClientAppIdAsync_WhenPreferLocalConfig_AndTenantMismatch_FallsBackToEntra() + { + // Arrange: local config has a different tenantId + const string activeTenantId = "active-tenant"; + const string configTenantId = "stale-tenant"; + const string entraClientAppId = "entra-resolved-app-id"; + + var configJson = $"{{\"tenantId\":\"{configTenantId}\",\"clientAppId\":\"stale-client-id\"}}"; + var configPath = Path.Combine(_tempDir, ConfigConstants.DefaultConfigFileName); + await File.WriteAllTextAsync(configPath, configJson); + + _mockGraph.FindApplicationByDisplayNameAsync( + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(entraClientAppId)); + + var originalCwd = Environment.CurrentDirectory; + Environment.CurrentDirectory = _tempDir; + try + { + // Act + var result = await SetupHelpers.ResolveBootstrapClientAppIdAsync( + activeTenantId, _mockGraph, NullLogger.Instance, CancellationToken.None, + preferLocalConfig: true); + + // Assert + result.Should().Be(entraClientAppId, + because: "when the local config tenant does not match the active tenant, the Entra lookup must be used"); + } + finally + { + Environment.CurrentDirectory = originalCwd; + } + } + + // ── GetJsonString ───────────────────────────────────────────────────────── + + [Theory] + [InlineData("{\"key\":\"value\"}", "key", "value")] + [InlineData("{\"key\":\"\"}", "key", "")] + [InlineData("{\"key\":null}", "key", null)] + [InlineData("{\"other\":\"x\"}", "key", null)] + [InlineData("{\"key\":42}", "key", null)] + public void GetJsonString_ReturnsExpected(string json, string key, string? expected) + { + using var doc = System.Text.Json.JsonDocument.Parse(json); + var result = SetupHelpers.GetJsonString(doc.RootElement, key); + result.Should().Be(expected); + } +} 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 d099434e..17551e0e 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.ObservabilityApiOtelWriteScope}"), - because: "scope URIs are Uri.EscapeDataString-encoded in the query string — required by AAD for adminconsent"); + 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)"); } [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.ObservabilityApiOtelWriteScope}"), - 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($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}")); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySummaryTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySummaryTests.cs deleted file mode 100644 index 9a7c6c7d..00000000 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySummaryTests.cs +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using FluentAssertions; -using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Xunit; - -namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Helpers; - -/// -/// Unit tests for SetupHelpers.DisplaySetupSummary method -/// -public class SetupHelpersDisplaySummaryTests -{ - private readonly ILogger _mockLogger; - private readonly List _logMessages; - - public SetupHelpersDisplaySummaryTests() - { - _mockLogger = Substitute.For(); - _logMessages = new List(); - - // Capture log messages for verification - _mockLogger.When(x => x.Log( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any>())) - .Do(callInfo => - { - var state = callInfo.ArgAt(2); - if (state != null) - { - _logMessages.Add(state.ToString() ?? string.Empty); - } - }); - } - - [Fact] - public void DisplaySetupSummary_WithGraphPermissionsError_ShouldShowRecoveryAction() - { - // Arrange - var results = new SetupResults - { - BlueprintCreated = true, - GraphInheritablePermissionsError = "Test error" - }; - results.Warnings.Add($"Microsoft Graph inheritable permissions: {results.GraphInheritablePermissionsError}"); - - // Act - SetupHelpers.DisplaySetupSummary(results, _mockLogger); - - // Assert - Verify recovery action is shown - _logMessages.Should().Contain(m => m.Contains("Graph Inheritable Permissions")); - _logMessages.Should().Contain(m => m.Contains("a365 setup blueprint")); - } - - [Fact] - public void DisplaySetupSummary_WithNoGraphPermissionsError_ShouldNotShowRecoveryAction() - { - // Arrange - var results = new SetupResults - { - BlueprintCreated = true, - GraphInheritablePermissionsError = null - }; - - // Act - SetupHelpers.DisplaySetupSummary(results, _mockLogger); - - // Assert - Should not show Graph recovery action - _logMessages.Should().NotContain(m => m.Contains("Graph Inheritable Permissions: Run")); - } - - [Fact] - public void DisplaySetupSummary_WithWarningsButNoGraphError_ShouldShowWarningsSection() - { - // Arrange - var results = new SetupResults - { - BlueprintCreated = true, - GraphInheritablePermissionsError = null - }; - results.Warnings.Add("Some other warning"); - - // Act - SetupHelpers.DisplaySetupSummary(results, _mockLogger); - - // Assert - _logMessages.Should().Contain(m => m.Contains("Warnings:")); - _logMessages.Should().Contain(m => m.Contains("Some other warning")); - } - - [Fact] - public void DisplaySetupSummary_WithGraphErrorAndOtherWarnings_ShouldShowBothRecoveryActions() - { - // Arrange - var results = new SetupResults - { - BlueprintCreated = true, - GraphInheritablePermissionsError = "Permission denied" - }; - results.Warnings.Add($"Microsoft Graph inheritable permissions: {results.GraphInheritablePermissionsError}"); - - // Act - SetupHelpers.DisplaySetupSummary(results, _mockLogger); - - // Assert - _logMessages.Should().Contain(m => m.Contains("Setup completed successfully with warnings")); - _logMessages.Should().Contain(m => m.Contains("Recovery Actions:")); - _logMessages.Should().Contain(m => m.Contains("Graph Inheritable Permissions")); - } - - [Fact] - public void DisplaySetupSummary_WithErrors_ShouldShowErrorRecoveryActions() - { - // Arrange - var results = new SetupResults - { - BlueprintCreated = false, - McpPermissionsConfigured = false, - BotApiPermissionsConfigured = false - }; - results.Errors.Add("Blueprint creation failed"); - - // Act - SetupHelpers.DisplaySetupSummary(results, _mockLogger); - - // Assert - _logMessages.Should().Contain(m => m.Contains("Setup completed with errors")); - _logMessages.Should().Contain(m => m.Contains("Recovery Actions:")); - } - - [Fact] - public void DisplaySetupSummary_AllSuccessful_ShouldNotShowWarningsOrErrors() - { - // Arrange - var results = new SetupResults - { - InfrastructureCreated = true, - BlueprintCreated = true, - McpPermissionsConfigured = true, - BotApiPermissionsConfigured = true, - MessagingEndpointRegistered = true, - InheritablePermissionsConfigured = true, - GraphInheritablePermissionsConfigured = true, - GraphInheritablePermissionsError = null - }; - - // Act - SetupHelpers.DisplaySetupSummary(results, _mockLogger); - - // Assert - _logMessages.Should().Contain(m => m.Contains("Setup completed successfully")); - _logMessages.Should().Contain(m => m.Contains("All components configured correctly")); - _logMessages.Should().NotContain(m => m.Contains("Recovery Actions:")); - } - - [Fact] - public void DisplaySetupSummary_WithGraphError_ShouldIndicatePartialSuccess() - { - // Arrange - var results = new SetupResults - { - InfrastructureCreated = true, - BlueprintCreated = true, - McpPermissionsConfigured = true, - BotApiPermissionsConfigured = true, - MessagingEndpointRegistered = true, - GraphInheritablePermissionsError = "Failed" - }; - results.Warnings.Add($"Microsoft Graph inheritable permissions: {results.GraphInheritablePermissionsError}"); - - // Act - SetupHelpers.DisplaySetupSummary(results, _mockLogger); - - // Assert - Should indicate success with warnings, not failure - _logMessages.Should().Contain(m => m.Contains("Setup completed successfully with warnings")); - _logMessages.Should().NotContain(m => m.Contains("Setup completed with errors")); - } -} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/Agent365ConfigTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/Agent365ConfigTests.cs index d51f17ba..83321ba4 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/Agent365ConfigTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/Agent365ConfigTests.cs @@ -396,6 +396,30 @@ public void Validate_WithMessagingEndpoint_DoesNotRequireAppServiceFields() errors.Should().BeEmpty("messaging endpoint makes App Service fields optional"); } + [Fact] + public void Validate_WithNeedDeploymentFalseAndNoMessagingEndpoint_ReturnsNoError() + { + // Arrange — bootstrap config: externally hosted agent with no endpoint yet + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + ClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", + SubscriptionId = "11111111-1111-1111-1111-111111111111", + ResourceGroup = "test-rg", + AgentIdentityDisplayName = "Test Agent Identity", + DeploymentProjectPath = ".", + NeedDeployment = false + // MessagingEndpoint intentionally absent — filled in after the agent is deployed + }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().NotContain(e => e.Contains("messagingEndpoint"), + because: "messagingEndpoint is optional at config-validation time; SetupHelpers enforces it at registration time"); + } + [Fact] public void Validate_WithoutMessagingEndpoint_RequiresAppServiceFields() { @@ -1023,4 +1047,229 @@ public void DeserializeFromJson_WithCustomBlueprintPermissions_RestoresPermissio } #endregion + + #region AiTeammate and IsNonAiTeammate Tests + + [Theory] + [InlineData(false, true)] // aiTeammate=false → non-AI Teammate agent + [InlineData(true, false)] // aiTeammate=true → AI Teammate (digital worker) + [InlineData(null, false)] // not set → AI Teammate (default) + public void IsNonAiTeammate_ReturnsCorrectValue(bool? aiTeammate, bool expected) + { + var config = new Agent365Config { AiTeammate = aiTeammate }; + + config.IsNonAiTeammate.Should().Be(expected); + } + + [Fact] + public void AiTeammate_IsSerializedToJson_WithCorrectPropertyName() + { + var config = new Agent365Config { AiTeammate = false }; + + var json = JsonSerializer.Serialize(config); + + json.Should().Contain("\"aiTeammate\""); + json.Should().Contain("false"); + } + + [Fact] + public void AiTeammate_IsDeserializedFromJson() + { + const string json = "{\"aiTeammate\": false}"; + + var config = JsonSerializer.Deserialize(json); + + config.Should().NotBeNull(); + config!.AiTeammate.Should().BeFalse(); + config.IsNonAiTeammate.Should().BeTrue(); + } + + [Fact] + public void AiTeammate_IsNullByDefault_WhenNotSpecified() + { + var config = new Agent365Config(); + + config.AiTeammate.Should().BeNull(); + config.IsNonAiTeammate.Should().BeFalse(); + } + + [Fact] + public void AzureOpenAIProperties_AreSerializedCorrectly() + { + var config = new Agent365Config + { + AzureOpenAIName = "aoai-test", + AzureOpenAILocation = "swedencentral", + AzureOpenAIModelDeploymentName = "gpt-4.1", + NeedAzureOpenAI = true + }; + + var json = JsonSerializer.Serialize(config); + + json.Should().Contain("\"azureOpenAIName\""); + json.Should().Contain("aoai-test"); + json.Should().Contain("\"azureOpenAILocation\""); + json.Should().Contain("swedencentral"); + json.Should().Contain("\"azureOpenAIModelDeploymentName\""); + json.Should().Contain("gpt-4.1"); + json.Should().Contain("\"needAzureOpenAI\""); + } + + [Theory] + [InlineData(false, true, true)] // aiTeammate=false + useBlueprint=true → blueprint non-DW + [InlineData(false, false, false)] // aiTeammate=false + useBlueprint=false → app-based non-DW + [InlineData(false, null, false)] // aiTeammate=false + useBlueprint not set → app-based non-DW + [InlineData(true, true, false)] // aiTeammate=true (DW) → never blueprint non-DW + [InlineData(null, true, false)] // not set (DW default) → never blueprint non-DW + public void IsNonDwBlueprint_ReturnsCorrectValue(bool? aiTeammate, bool? useBlueprint, bool expected) + { + var config = new Agent365Config { AiTeammate = aiTeammate, UseBlueprint = useBlueprint }; + + config.IsNonDwBlueprint.Should().Be(expected); + } + + [Fact] + public void UseBlueprint_IsSerializedToJson_WithCorrectPropertyName() + { + var config = new Agent365Config { UseBlueprint = true }; + + var json = JsonSerializer.Serialize(config); + + json.Should().Contain("\"useBlueprint\""); + json.Should().Contain("true"); + } + + [Fact] + public void UseBlueprint_IsDeserializedFromJson() + { + const string json = "{\"aiTeammate\": false, \"useBlueprint\": true}"; + + var config = JsonSerializer.Deserialize(json); + + config.Should().NotBeNull(); + config!.UseBlueprint.Should().BeTrue(); + config.IsNonDwBlueprint.Should().BeTrue(); + } + + [Fact] + public void WithCustomBlueprintPermissions_PreservesAiTeammate() + { + var config = new Agent365Config + { + AiTeammate = false, + UseBlueprint = true, + AzureOpenAIName = "aoai-test", + NeedAzureOpenAI = true + }; + + var cloned = config.WithCustomBlueprintPermissions(null); + + cloned.AiTeammate.Should().BeFalse(); + cloned.UseBlueprint.Should().BeTrue(); + cloned.AzureOpenAIName.Should().Be("aoai-test"); + cloned.NeedAzureOpenAI.Should().BeTrue(); + } + + #endregion + + #region ValidateNonDwMinimal Tests + + [Fact] + public void ValidateNonDwMinimal_ValidMinimalConfig_ReturnsNoErrors() + { + var config = new Agent365Config + { + TenantId = "tenant-id", + ClientAppId = "f2d098d5-09d2-40e1-a7b0-d9fff1ace230", + AgentIdentityDisplayName = "My Agent" + }; + + var errors = config.ValidateNonDwMinimal(); + + errors.Should().BeEmpty( + because: "a config with valid tenantId, clientAppId (GUID), and agentIdentityDisplayName meets the minimal bootstrap requirements"); + } + + [Fact] + public void ValidateNonDwMinimal_MissingTenantId_ReturnsError() + { + var config = new Agent365Config + { + TenantId = "", + ClientAppId = "f2d098d5-09d2-40e1-a7b0-d9fff1ace230", + AgentIdentityDisplayName = "My Agent" + }; + + var errors = config.ValidateNonDwMinimal(); + + errors.Should().ContainMatch("*tenantId*", + because: "tenantId is required for the bootstrap path to acquire tokens"); + } + + [Fact] + public void ValidateNonDwMinimal_MissingClientAppId_ReturnsError() + { + var config = new Agent365Config + { + TenantId = "tenant-id", + ClientAppId = "", + AgentIdentityDisplayName = "My Agent" + }; + + var errors = config.ValidateNonDwMinimal(); + + errors.Should().ContainMatch("*clientAppId*", + because: "clientAppId is required to authenticate against Graph and ARM; an empty value means the well-known app lookup failed"); + } + + [Fact] + public void ValidateNonDwMinimal_NonGuidClientAppId_ReturnsError() + { + var config = new Agent365Config + { + TenantId = "tenant-id", + ClientAppId = "not-a-guid", + AgentIdentityDisplayName = "My Agent" + }; + + var errors = config.ValidateNonDwMinimal(); + + errors.Should().NotBeEmpty( + because: "clientAppId must be a valid GUID for MSAL to accept it as an application ID"); + } + + [Fact] + public void ValidateNonDwMinimal_MissingAgentIdentityDisplayName_ReturnsError() + { + var config = new Agent365Config + { + TenantId = "tenant-id", + ClientAppId = "f2d098d5-09d2-40e1-a7b0-d9fff1ace230", + AgentIdentityDisplayName = "" + }; + + var errors = config.ValidateNonDwMinimal(); + + errors.Should().ContainMatch("*agentIdentityDisplayName*", + because: "agentIdentityDisplayName is required to name the Entra app registration created for the agent identity"); + } + + [Fact] + public void ValidateNonDwMinimal_DoesNotRequireSubscriptionId() + { + var config = new Agent365Config + { + TenantId = "tenant-id", + ClientAppId = "f2d098d5-09d2-40e1-a7b0-d9fff1ace230", + AgentIdentityDisplayName = "My Agent" + // SubscriptionId, ResourceGroup, DeploymentProjectPath intentionally omitted + }; + + var errors = config.ValidateNonDwMinimal(); + + errors.Should().BeEmpty( + because: "bootstrap (--agent-name) path uses external hosting — no Azure subscription or deployment path is required"); + } + + #endregion } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ConfigServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ConfigServiceTests.cs index 70843db5..3aea39b3 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ConfigServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ConfigServiceTests.cs @@ -56,6 +56,7 @@ public async Task LoadAsync_LoadsStaticConfigOnly_WhenStateFileDoesNotExist() var staticConfig = new { tenantId = "12345678-1234-1234-1234-123456789012", + clientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", subscriptionId = "87654321-4321-4321-4321-210987654321", resourceGroup = "rg-test", location = "eastus", @@ -91,6 +92,7 @@ public async Task LoadAsync_MergesStaticAndDynamicConfig_WhenBothFilesExist() var staticConfig = new { tenantId = "12345678-1234-1234-1234-123456789012", + clientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", subscriptionId = "87654321-4321-4321-4321-210987654321", resourceGroup = "rg-test", location = "eastus", @@ -280,45 +282,32 @@ public async Task SaveStateAsync_SavesLocallyWhenStaticConfigExists() } [Fact] - public async Task SaveStateAsync_SavesGloballyWhenNoStaticConfigExists() + public async Task SaveStateAsync_SavesLocallyEvenWhenNoStaticConfigExists() { - // Arrange - Use a directory without a static config + // Global config directory fallback was removed — SaveStateAsync always writes + // to the current directory regardless of whether a static config exists there. var tempDir = Path.Combine(Path.GetTempPath(), $"agent365-noproj-{Guid.NewGuid()}"); Directory.CreateDirectory(tempDir); - + try { var originalDir = Environment.CurrentDirectory; Environment.CurrentDirectory = tempDir; - + try { - // Create a config to save var config = new Agent365Config { TenantId = "12345678-1234-1234-1234-123456789012" }; config.AgentBlueprintId = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"; - // Get global config path - var globalDir = ConfigService.GetGlobalConfigDirectory(); - var globalStatePath = Path.Combine(globalDir, ConfigConstants.DefaultStateFileName); - - // Delete global state if it exists to ensure clean test - if (File.Exists(globalStatePath)) - { - File.Delete(globalStatePath); - } - - // Act - Save state (should go to global directory, NOT local) + // Act await _service.SaveStateAsync(config, ConfigConstants.DefaultStateFileName); - // Assert - State should be saved globally - Assert.True(File.Exists(globalStatePath), "Global state file should exist when no local config present"); - - var globalContent = await File.ReadAllTextAsync(globalStatePath); - Assert.Contains("bbbbbbbb-cccc-dddd-eeee-ffffffffffff", globalContent); - - // Assert - State should NOT be saved to current directory + // Assert — state is always saved to the current directory var localStatePath = Path.Combine(tempDir, ConfigConstants.DefaultStateFileName); - Assert.False(File.Exists(localStatePath), "Local state file should NOT exist when no static config present"); + Assert.True(File.Exists(localStatePath), + "State file should always be saved to the current directory"); + var content = await File.ReadAllTextAsync(localStatePath); + Assert.Contains("bbbbbbbb-cccc-dddd-eeee-ffffffffffff", content); } finally { @@ -328,9 +317,7 @@ public async Task SaveStateAsync_SavesGloballyWhenNoStaticConfigExists() finally { if (Directory.Exists(tempDir)) - { Directory.Delete(tempDir, recursive: true); - } } } @@ -345,6 +332,7 @@ public async Task ValidateAsync_ReturnsSuccess_ForValidConfig() var config = new Agent365Config { TenantId = "12345678-1234-1234-1234-123456789012", + ClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", SubscriptionId = "87654321-4321-4321-4321-210987654321", ResourceGroup = "rg-test", Location = "eastus", @@ -375,12 +363,12 @@ public async Task ValidateAsync_ReturnsErrors_ForMissingRequiredFields() // Act var result = await _service.ValidateAsync(config); - // Assert + // Assert — error messages use camelCase field names (from Agent365Config.Validate()) Assert.False(result.IsValid); - Assert.Contains(result.Errors, e => e.Contains("TenantId")); - Assert.Contains(result.Errors, e => e.Contains("SubscriptionId")); - Assert.Contains(result.Errors, e => e.Contains("ResourceGroup")); - Assert.Contains(result.Errors, e => e.Contains("Location")); + Assert.Contains(result.Errors, e => e.Contains("tenantId")); + Assert.Contains(result.Errors, e => e.Contains("subscriptionId")); + Assert.Contains(result.Errors, e => e.Contains("resourceGroup")); + Assert.Contains(result.Errors, e => e.Contains("location")); } [Fact] diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs index 2abc1775..5f20e7d8 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs @@ -272,7 +272,7 @@ public async Task DeleteAgentIdentityAsync_WithValidIdentity_ReturnsTrue() // Override with specific scope assertion _mockTokenProvider.GetMgGraphAccessTokenAsync( tenantId, - Arg.Is>(scopes => scopes.Contains("AgentIdentityBlueprint.DeleteRestore.All")), + Arg.Is>(scopes => scopes.Contains("AgentIdentity.DeleteRestore.All")), false, Arg.Any(), Arg.Any(), @@ -289,7 +289,7 @@ public async Task DeleteAgentIdentityAsync_WithValidIdentity_ReturnsTrue() await _mockTokenProvider.Received(1).GetMgGraphAccessTokenAsync( tenantId, - Arg.Is>(scopes => scopes.Contains("AgentIdentityBlueprint.DeleteRestore.All")), + Arg.Is>(scopes => scopes.Contains("AgentIdentity.DeleteRestore.All")), false, Arg.Any(), Arg.Any(), @@ -542,7 +542,9 @@ public ThrowingOnPatchGraphApiService( CommandExecutor executor, IAuthenticationService authService, HttpMessageHandler handler) - : base(logger, executor, authService, handler) { } + // loginHintResolver: no-op — prevents AzCliHelper.ResolveLoginHintAsync() from + // spawning a real 'az account get-access-token' subprocess in tests. + : base(logger, executor, authService, handler, loginHintResolver: () => Task.FromResult(null)) { } public override Task GraphPatchAsync( string tenantId, diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs index 1dc0c502..0c587b28 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs @@ -33,13 +33,19 @@ public class ClientAppValidatorTests // Stable test GUIDs for required permissions — must match between SetupPermissionResolution // and SetupAppInfoWithAllPermissions so the validation resolves all permissions as present. - private const string ApplicationReadWriteAllId = "aaaa0001-0000-0000-0000-000000000000"; + private const string AgentBlueprintPrincipalCreateId = "aaaa0001-0000-0000-0000-000000000000"; private const string AgentBlueprintReadWriteAllId = "aaaa0002-0000-0000-0000-000000000000"; private const string AgentBlueprintUpdateAuthId = "aaaa0003-0000-0000-0000-000000000000"; private const string AgentBlueprintAddRemoveCredsId = "aaaa0004-0000-0000-0000-000000000000"; private const string DelegatedPermissionGrantReadWriteAllId = "aaaa0005-0000-0000-0000-000000000000"; private const string DirectoryReadAllId = "aaaa0006-0000-0000-0000-000000000000"; - private const string UserReadWriteAllId = "aaaa0007-0000-0000-0000-000000000000"; + private const string AgentInstanceReadWriteAllId = "aaaa0007-0000-0000-0000-000000000000"; + private const string UserReadId = "aaaa0008-0000-0000-0000-000000000000"; + private const string UserReadWriteAllId = "aaaa0009-0000-0000-0000-000000000000"; + + // Separate SP object ID used only by the consent-grant path (GetConsentedPermissionsAsync) + // so it does not conflict with SetupAdminConsentSp / SetupAdminConsentGrantsEmpty. + private const string ConsentSpObjId = "consent-check-sp-id-999"; public ClientAppValidatorTests() { @@ -185,13 +191,13 @@ await Assert.ThrowsAsync( [Fact] public async Task EnsureValidClientAppAsync_WhenAppMissingSomePermissions_ThrowsClientAppValidationException() { - // Only Application.ReadWrite.All present — missing the other 5 + // Only AgentIdentityBlueprintPrincipal.Create present — missing the other required permissions var requiredResourceAccess = $$""" [ { "resourceAppId": "{{AuthenticationConstants.MicrosoftGraphResourceAppId}}", "resourceAccess": [ - {"id": "{{ApplicationReadWriteAllId}}", "type": "Scope"} + {"id": "{{AgentBlueprintPrincipalCreateId}}", "type": "Scope"} ] } ] @@ -337,12 +343,15 @@ private ClientAppValidator CreateValidatorWithConfirmation(IConfirmationProvider { "resourceAppId": "{{AuthenticationConstants.MicrosoftGraphResourceAppId}}", "resourceAccess": [ - {"id": "{{ApplicationReadWriteAllId}}", "type": "Scope"}, + {"id": "{{AgentBlueprintPrincipalCreateId}}", "type": "Scope"}, {"id": "{{AgentBlueprintReadWriteAllId}}", "type": "Scope"}, {"id": "{{AgentBlueprintUpdateAuthId}}", "type": "Scope"}, {"id": "{{AgentBlueprintAddRemoveCredsId}}", "type": "Scope"}, {"id": "{{DelegatedPermissionGrantReadWriteAllId}}", "type": "Scope"}, - {"id": "{{DirectoryReadAllId}}", "type": "Scope"} + {"id": "{{DirectoryReadAllId}}", "type": "Scope"}, + {"id": "{{AgentInstanceReadWriteAllId}}", "type": "Scope"}, + {"id": "{{UserReadId}}", "type": "Scope"}, + {"id": "{{UserReadWriteAllId}}", "type": "Scope"} ] } ] @@ -377,12 +386,15 @@ private ClientAppValidator CreateValidatorWithConfirmation(IConfirmationProvider "value": [{ "id": "graph-sp-id-123", "oauth2PermissionScopes": [ - {"id": "{{ApplicationReadWriteAllId}}", "value": "Application.ReadWrite.All"}, + {"id": "{{AgentBlueprintPrincipalCreateId}}", "value": "AgentIdentityBlueprintPrincipal.Create"}, {"id": "{{AgentBlueprintReadWriteAllId}}", "value": "AgentIdentityBlueprint.ReadWrite.All"}, {"id": "{{AgentBlueprintUpdateAuthId}}", "value": "AgentIdentityBlueprint.UpdateAuthProperties.All"}, {"id": "{{AgentBlueprintAddRemoveCredsId}}", "value": "AgentIdentityBlueprint.AddRemoveCreds.All"}, {"id": "{{DelegatedPermissionGrantReadWriteAllId}}", "value": "DelegatedPermissionGrant.ReadWrite.All"}, - {"id": "{{DirectoryReadAllId}}", "value": "Directory.Read.All"} + {"id": "{{DirectoryReadAllId}}", "value": "Directory.Read.All"}, + {"id": "{{AgentInstanceReadWriteAllId}}", "value": "AgentInstance.ReadWrite.All"}, + {"id": "{{UserReadId}}", "value": "User.Read"}, + {"id": "{{UserReadWriteAllId}}", "value": "User.ReadWrite.All"} ] }] } @@ -437,7 +449,7 @@ public async Task EnsureValidClientAppAsync_WhenUserDeclinesWithMissingPermissio "value": [{ "id": "graph-sp-id-123", "oauth2PermissionScopes": [ - {"id": "{{ApplicationReadWriteAllId}}", "value": "Application.ReadWrite.All"}, + {"id": "{{AgentBlueprintPrincipalCreateId}}", "value": "AgentIdentityBlueprintPrincipal.Create"}, {"id": "{{DelegatedPermissionGrantReadWriteAllId}}", "value": "DelegatedPermissionGrant.ReadWrite.All"} ] }] @@ -451,6 +463,9 @@ public async Task EnsureValidClientAppAsync_WhenUserDeclinesWithMissingPermissio Arg.Any?>()) .Returns(_ => Task.FromResult(JsonDocument.Parse(permJson))); + graphApiService.CheckServicePrincipalCreationPrivilegesAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult((true, new List { "Global Administrator" }))); + var validator = new ClientAppValidator(_logger, graphApiService, confirmationProvider); var exception = await Assert.ThrowsAsync( @@ -497,6 +512,8 @@ public async Task EnsureValidClientAppAsync_WhenUserDeclinesWithMissingRedirectU // Build a fresh validator wired to _graphApiService so the redirect URI mock is reachable SetupAppInfoWithAllPermissions(ValidClientAppId); SetupPermissionResolution(); + _graphApiService.CheckServicePrincipalCreationPrivilegesAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult((true, new List { "Global Administrator" }))); var validatorWithSharedGraph = new ClientAppValidator(_logger, _graphApiService, confirmationProvider); var exception = await Assert.ThrowsAsync( @@ -519,6 +536,8 @@ public async Task EnsureValidClientAppAsync_WhenUserDeclinesWithPublicClientDisa SetupAppInfoWithAllPermissions(ValidClientAppId); SetupPermissionResolution(); SetupPublicClientFlowsGet(enabled: false); + _graphApiService.CheckServicePrincipalCreationPrivilegesAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult((true, new List { "Global Administrator" }))); var validator = new ClientAppValidator(_logger, _graphApiService, confirmationProvider); @@ -559,6 +578,9 @@ public async Task EnsureValidClientAppAsync_WhenUserDeclinesWithAllMutationsPend Arg.Any?>()) .Returns(_ => Task.FromResult(JsonDocument.Parse(redirectUriJson))); + _graphApiService.CheckServicePrincipalCreationPrivilegesAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult((true, new List { "Global Administrator" }))); + var validator = new ClientAppValidator(_logger, _graphApiService, confirmationProvider); var exception = await Assert.ThrowsAsync( @@ -583,6 +605,8 @@ public async Task EnsureValidClientAppAsync_WhenUserAcceptsConfirmation_Proceeds Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()) .Returns(Task.FromResult(true)); + _graphApiService.CheckServicePrincipalCreationPrivilegesAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult((true, new List { "Global Administrator" }))); var validator = new ClientAppValidator(_logger, _graphApiService, confirmationProvider); @@ -798,8 +822,10 @@ private void SetupAppInfoGetEmpty() } /// - /// Sets up the app info GET with all 7 required permissions. + /// Sets up the app info GET with all required permissions (8 with GUIDs + AgentIdentity.Create.All, + /// AgentIdentityBlueprint.DeleteRestore.All, and AgentIdentity.DeleteRestore.All via consent grant). /// The permission GUIDs match those returned by SetupPermissionResolution so validation passes. + /// The three no-GUID scopes are resolved via GetConsentedPermissionsAsync fallback. /// private void SetupAppInfoWithAllPermissions(string appId) { @@ -808,12 +834,14 @@ private void SetupAppInfoWithAllPermissions(string appId) { "resourceAppId": "{{AuthenticationConstants.MicrosoftGraphResourceAppId}}", "resourceAccess": [ - {"id": "{{ApplicationReadWriteAllId}}", "type": "Scope"}, + {"id": "{{AgentBlueprintPrincipalCreateId}}", "type": "Scope"}, {"id": "{{AgentBlueprintReadWriteAllId}}", "type": "Scope"}, {"id": "{{AgentBlueprintUpdateAuthId}}", "type": "Scope"}, {"id": "{{AgentBlueprintAddRemoveCredsId}}", "type": "Scope"}, {"id": "{{DelegatedPermissionGrantReadWriteAllId}}", "type": "Scope"}, {"id": "{{DirectoryReadAllId}}", "type": "Scope"}, + {"id": "{{AgentInstanceReadWriteAllId}}", "type": "Scope"}, + {"id": "{{UserReadId}}", "type": "Scope"}, {"id": "{{UserReadWriteAllId}}", "type": "Scope"} ] } @@ -821,6 +849,7 @@ private void SetupAppInfoWithAllPermissions(string appId) """; SetupAppInfoGet(appId, requiredResourceAccess: requiredResourceAccess); + SetupConsentGrantForAgentIdentityCreate(); } /// @@ -835,12 +864,14 @@ private void SetupPermissionResolution() { "id": "graph-sp-id-123", "oauth2PermissionScopes": [ - {"id": "{{ApplicationReadWriteAllId}}", "value": "Application.ReadWrite.All"}, + {"id": "{{AgentBlueprintPrincipalCreateId}}", "value": "AgentIdentityBlueprintPrincipal.Create"}, {"id": "{{AgentBlueprintReadWriteAllId}}", "value": "AgentIdentityBlueprint.ReadWrite.All"}, {"id": "{{AgentBlueprintUpdateAuthId}}", "value": "AgentIdentityBlueprint.UpdateAuthProperties.All"}, {"id": "{{AgentBlueprintAddRemoveCredsId}}", "value": "AgentIdentityBlueprint.AddRemoveCreds.All"}, {"id": "{{DelegatedPermissionGrantReadWriteAllId}}", "value": "DelegatedPermissionGrant.ReadWrite.All"}, {"id": "{{DirectoryReadAllId}}", "value": "Directory.Read.All"}, + {"id": "{{AgentInstanceReadWriteAllId}}", "value": "AgentInstance.ReadWrite.All"}, + {"id": "{{UserReadId}}", "value": "User.Read"}, {"id": "{{UserReadWriteAllId}}", "value": "User.ReadWrite.All"} ] } @@ -856,6 +887,34 @@ private void SetupPermissionResolution() .Returns(_ => Task.FromResult(JsonDocument.Parse(json))); } + /// + /// Sets up the consent-grant fallback path for AgentIdentity.Create.All. + /// This permission has no GUID in v1.0 oauth2PermissionScopes, so ClientAppValidator + /// resolves it via GetConsentedPermissionsAsync (step 3.5). Uses a distinct SP object ID + /// (ConsentSpObjId) so this mock does not interfere with SetupAdminConsentSp/SetupAdminConsentGrantsEmpty. + /// + private void SetupConsentGrantForAgentIdentityCreate() + { + // SP lookup used by GetConsentedPermissionsAsync: $select=id (no extra fields). + // Discriminated from ValidateAdminConsentAsync ($select=id,appId) by EndsWith. + var spJson = $$"""{"value": [{"id": "{{ConsentSpObjId}}"}]}"""; + _graphApiService.GraphGetAsync( + Arg.Any(), + Arg.Is(p => p.Contains("servicePrincipals") && p.EndsWith("&$select=id")), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(JsonDocument.Parse(spJson))); + + // Grants for ConsentSpObjId — contains all three no-GUID scopes so they are removed from missingPermissions. + var grantsJson = """{"value": [{"scope": "AgentIdentity.Create.All AgentIdentityBlueprint.DeleteRestore.All AgentIdentity.DeleteRestore.All"}]}"""; + _graphApiService.GraphGetAsync( + Arg.Any(), + Arg.Is(p => p.Contains("oauth2PermissionGrants") && p.Contains(ConsentSpObjId)), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(JsonDocument.Parse(grantsJson))); + } + /// /// Sets up the admin consent SP GET (select includes id,appId — used by ValidateAdminConsentAsync). /// diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceRegisterAgentInstanceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceRegisterAgentInstanceTests.cs new file mode 100644 index 00000000..dcc7cd25 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceRegisterAgentInstanceTests.cs @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +/// +/// Tests for GraphApiService.RegisterAgentInstanceAsync. +/// Verifies correct API call shape, success path, and error handling. +/// +public class GraphApiServiceRegisterAgentInstanceTests +{ + private static CommandExecutor BuildMockExecutor() + { + var executor = Substitute.For(Substitute.For>()); + executor.ExecuteAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var args = callInfo.ArgAt(1); + if (args != null && args.StartsWith("account show", StringComparison.OrdinalIgnoreCase)) + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "{}", StandardError = string.Empty }); + if (args != null && args.Contains("get-access-token", StringComparison.OrdinalIgnoreCase)) + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "fake-token", StandardError = string.Empty }); + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); + }); + return executor; + } + + private static GraphApiService BuildService(HttpMessageHandler handler) + { + var authService = Substitute.For(); + authService.GetAccessTokenAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult("fake-graph-token")); + return new GraphApiService( + Substitute.For>(), + BuildMockExecutor(), + authService, + handler, + tokenProvider: null, + loginHintResolver: () => Task.FromResult(null), + agentRegistryRetryDelay: TimeSpan.Zero); + } + + [Fact] + public async Task RegisterAgentInstanceAsync_ReturnsInstanceId_OnSuccess() + { + using var handler = new TestHttpMessageHandler(); + + // GET /v1.0/me response + handler.QueueResponse(new System.Net.Http.HttpResponseMessage(HttpStatusCode.OK) + { + Content = new System.Net.Http.StringContent(JsonSerializer.Serialize(new { id = "user-object-id" })) + }); + + // POST /beta/agentRegistry/agentInstances response + handler.QueueResponse(new System.Net.Http.HttpResponseMessage(HttpStatusCode.Created) + { + Content = new System.Net.Http.StringContent(JsonSerializer.Serialize(new { id = "instance-id-123" })) + }); + + var service = BuildService(handler); + + var result = await service.RegisterAgentInstanceAsync( + tenantId: "tenant-id", + displayName: "My Agent", + agentBlueprintId: "blueprint-id"); + + result.Should().Be("instance-id-123"); + } + + [Fact] + public async Task RegisterAgentInstanceAsync_ReturnsNull_WhenMeCallFails() + { + using var handler = new TestHttpMessageHandler(); + + // GET /v1.0/me fails + handler.QueueResponse(new System.Net.Http.HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new System.Net.Http.StringContent("{\"error\":{\"message\":\"Unauthorized\"}}") + }); + + var service = BuildService(handler); + + var result = await service.RegisterAgentInstanceAsync( + tenantId: "tenant-id", + displayName: "My Agent", + agentBlueprintId: null); + + result.Should().BeNull(); + } + + [Fact] + public async Task RegisterAgentInstanceAsync_ReturnsNull_WhenPostFails() + { + using var handler = new TestHttpMessageHandler(); + + handler.QueueResponse(new System.Net.Http.HttpResponseMessage(HttpStatusCode.OK) + { + Content = new System.Net.Http.StringContent(JsonSerializer.Serialize(new { id = "user-object-id" })) + }); + + handler.QueueResponse(new System.Net.Http.HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new System.Net.Http.StringContent("{\"error\":{\"message\":\"Forbidden\"}}") + }); + + var service = BuildService(handler); + + var result = await service.RegisterAgentInstanceAsync( + tenantId: "tenant-id", + displayName: "My Agent", + agentBlueprintId: "blueprint-id"); + + result.Should().BeNull(); + } + + [Fact] + public async Task RegisterAgentInstanceAsync_IncludesBlueprintId_InPayload() + { + string? capturedBody = null; + + using var handler = new CapturingHttpMessageHandler(req => + { + if (req.Method == System.Net.Http.HttpMethod.Post) + capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }); + + handler.QueueResponse(new System.Net.Http.HttpResponseMessage(HttpStatusCode.OK) + { + Content = new System.Net.Http.StringContent(JsonSerializer.Serialize(new { id = "user-object-id" })) + }); + + handler.QueueResponse(new System.Net.Http.HttpResponseMessage(HttpStatusCode.Created) + { + Content = new System.Net.Http.StringContent(JsonSerializer.Serialize(new { id = "instance-id-abc" })) + }); + + var service = BuildService(handler); + + await service.RegisterAgentInstanceAsync( + tenantId: "tenant-id", + displayName: "My Agent", + agentBlueprintId: "bp-id-xyz"); + + capturedBody.Should().Contain("bp-id-xyz"); + capturedBody.Should().Contain("agentIdentityBlueprintId"); + } + + [Fact] + public async Task RegisterAgentInstanceAsync_OmitsBlueprintId_WhenNull() + { + string? capturedBody = null; + + using var handler = new CapturingHttpMessageHandler(req => + { + if (req.Method == System.Net.Http.HttpMethod.Post) + capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }); + + handler.QueueResponse(new System.Net.Http.HttpResponseMessage(HttpStatusCode.OK) + { + Content = new System.Net.Http.StringContent(JsonSerializer.Serialize(new { id = "user-object-id" })) + }); + + handler.QueueResponse(new System.Net.Http.HttpResponseMessage(HttpStatusCode.Created) + { + Content = new System.Net.Http.StringContent(JsonSerializer.Serialize(new { id = "instance-id-abc" })) + }); + + var service = BuildService(handler); + + await service.RegisterAgentInstanceAsync( + tenantId: "tenant-id", + displayName: "My Agent", + agentBlueprintId: null); + + capturedBody.Should().NotContain("agentIdentityBlueprintId"); + } + + [Fact] + public async Task RegisterAgentInstanceAsync_IncludesDisplayName_InPayload() + { + string? capturedBody = null; + + using var handler = new CapturingHttpMessageHandler(req => + { + if (req.Method == System.Net.Http.HttpMethod.Post) + capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + }); + + handler.QueueResponse(new System.Net.Http.HttpResponseMessage(HttpStatusCode.OK) + { + Content = new System.Net.Http.StringContent(JsonSerializer.Serialize(new { id = "user-object-id" })) + }); + + handler.QueueResponse(new System.Net.Http.HttpResponseMessage(HttpStatusCode.Created) + { + Content = new System.Net.Http.StringContent(JsonSerializer.Serialize(new { id = "instance-id-abc" })) + }); + + var service = BuildService(handler); + + await service.RegisterAgentInstanceAsync( + tenantId: "tenant-id", + displayName: "Contoso Agent", + agentBlueprintId: null); + + capturedBody.Should().Contain("Contoso Agent"); + capturedBody.Should().Contain("displayName"); + } + + [Fact] + public async Task RegisterAgentInstanceAsync_PostsToCorrectEndpoint() + { + System.Net.Http.HttpRequestMessage? capturedRequest = null; + + using var handler = new CapturingHttpMessageHandler(req => + { + if (req.Method == System.Net.Http.HttpMethod.Post) + capturedRequest = req; + }); + + handler.QueueResponse(new System.Net.Http.HttpResponseMessage(HttpStatusCode.OK) + { + Content = new System.Net.Http.StringContent(JsonSerializer.Serialize(new { id = "user-object-id" })) + }); + + handler.QueueResponse(new System.Net.Http.HttpResponseMessage(HttpStatusCode.Created) + { + Content = new System.Net.Http.StringContent(JsonSerializer.Serialize(new { id = "instance-id-abc" })) + }); + + var service = BuildService(handler); + + await service.RegisterAgentInstanceAsync("tenant-id", "My Agent", null); + + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString() + .Should().Contain("/beta/agentRegistry/agentInstances"); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs index d90de8b9..cb797c56 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs @@ -572,6 +572,114 @@ public async Task IsCurrentUserAdminAsync_GraphFails_ReturnsUnknown() #endregion + #region FindApplicationByDisplayNameAsync + + [Fact] + public async Task FindApplicationByDisplayNameAsync_WhenAppFound_ReturnsAppId() + { + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithHandler(handler); + + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"value\":[{\"appId\":\"f2d098d5-09d2-40e1-a7b0-d9fff1ace230\"}]}") + }); + + var result = await service.FindApplicationByDisplayNameAsync("tenant-id", "Agent 365 CLI"); + + result.Should().Be("f2d098d5-09d2-40e1-a7b0-d9fff1ace230", + because: "the first matching app's appId should be returned when Graph returns a non-empty value array"); + } + + [Fact] + public async Task FindApplicationByDisplayNameAsync_WhenNotFound_ReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithHandler(handler); + + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"value\":[]}") + }); + + var result = await service.FindApplicationByDisplayNameAsync("tenant-id", "Unknown App"); + + result.Should().BeNull( + because: "an empty value array means no app with that display name exists in the tenant"); + } + + [Fact] + public async Task FindApplicationByDisplayNameAsync_WhenApiFails_ReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithHandler(handler); + + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new StringContent("{\"error\":{\"code\":\"Authorization_RequestDenied\"}}") + }); + + var result = await service.FindApplicationByDisplayNameAsync("tenant-id", "Agent 365 CLI"); + + result.Should().BeNull( + because: "a non-success HTTP response should be treated as app-not-found to allow the caller to surface a clear error"); + } + + [Fact] + public async Task FindApplicationByDisplayNameAsync_SendsConsistencyLevelHeader() + { + // Graph requires 'ConsistencyLevel: eventual' for advanced filter queries (displayName eq). + // Missing this header causes HTTP 400 in some tenants. + HttpRequestMessage? capturedRequest = null; + using var handler = new CapturingHttpMessageHandler(req => capturedRequest = req); + var service = CreateServiceWithHandler(handler); + + var result = await service.FindApplicationByDisplayNameAsync("tenant-id", "Agent 365 CLI"); + + capturedRequest.Should().NotBeNull(); + capturedRequest!.Headers.Contains("ConsistencyLevel").Should().BeTrue( + because: "Graph advanced query filters (displayName eq) require 'ConsistencyLevel: eventual'"); + capturedRequest.Headers.GetValues("ConsistencyLevel").Should().Contain("eventual", + because: "the exact value 'eventual' is required by the Graph API spec for advanced queries"); + } + + [Fact] + public async Task FindApplicationByDisplayNameAsync_EscapesSingleQuotesInDisplayName() + { + // OData string literal escaping: ' must be doubled to '' + // Without this, a name like "O'Brien" would break the filter URL. + HttpRequestMessage? capturedRequest = null; + using var handler = new CapturingHttpMessageHandler(req => capturedRequest = req); + var service = CreateServiceWithHandler(handler); + + await service.FindApplicationByDisplayNameAsync("tenant-id", "O'Brien's App"); + + capturedRequest.Should().NotBeNull(); + // The URI may URL-encode spaces (%20) but must preserve '' as the OData escape for ' + var decodedQuery = Uri.UnescapeDataString(capturedRequest!.RequestUri!.Query); + decodedQuery.Should().Contain("O''Brien''s App", + because: "OData requires single quotes in string literals to be escaped by doubling: ' → ''"); + } + + private static GraphApiService CreateServiceWithHandler(HttpMessageHandler handler) + { + var logger = Substitute.For>(); + var executor = Substitute.For(Substitute.For>()); + executor.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var args = callInfo.ArgAt(1); + if (args != null && args.StartsWith("account show", StringComparison.OrdinalIgnoreCase)) + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "{}", StandardError = string.Empty }); + if (args != null && args.Contains("get-access-token", StringComparison.OrdinalIgnoreCase)) + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "fake-token", StandardError = string.Empty }); + return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); + }); + return new GraphApiService(logger, executor, FakeAuth(), handler, loginHintResolver: () => Task.FromResult(null)); + } + + #endregion + #region IsCurrentUserAgentIdAdminAsync private static IAuthenticationService FakeAuth() diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs index c6dcb42e..a0b3cc40 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs @@ -16,9 +16,9 @@ public class InteractiveGraphAuthServiceTests /// /// This test ensures that all required Graph API scopes are present in the RequiredScopes array. /// If any of these scopes are removed, the test will fail to prevent accidental permission reduction. - /// + /// /// These scopes are critical for Agent Blueprint creation and inheritable permissions configuration: - /// - Application.ReadWrite.All: Required for creating and managing app registrations + /// - AgentIdentityBlueprintPrincipal.Create: Required for blueprint SP creation (per Agent ID team — Kyle Marsh; ReadWrite.All is higher privilege and not needed) /// - AgentIdentityBlueprint.ReadWrite.All: Required for Agent Blueprint operations /// - AgentIdentityBlueprint.UpdateAuthProperties.All: Required for updating blueprint auth properties /// - User.Read: Basic user profile access for authentication context @@ -29,8 +29,8 @@ public void RequiredScopes_MustContainAllEssentialPermissions() // Arrange var expectedScopes = new[] { - "https://graph.microsoft.com/Application.ReadWrite.All", - "https://graph.microsoft.com/AgentIdentityBlueprint.ReadWrite.All", + "https://graph.microsoft.com/AgentIdentityBlueprintPrincipal.Create", + "https://graph.microsoft.com/AgentIdentityBlueprint.ReadWrite.All", "https://graph.microsoft.com/AgentIdentityBlueprint.UpdateAuthProperties.All", "https://graph.microsoft.com/User.Read" }; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs index dd87869b..07a016ba 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs @@ -382,4 +382,123 @@ public async Task GetMgGraphAccessTokenAsync_WithForceRefresh_BypassesCache() because: "forceRefresh: true must evict the cached token and re-invoke MSAL, " + "ensuring a stale CAE-revoked token is not reused"); } + + /// + /// Tests for the IsInteractiveBrowserFailure + device-code retry path. + /// This is the specific fix for 'a365 cleanup --agent-name' failing when PowerShell + /// Connect-MgGraph's interactive browser auth fails in an embedded terminal. + /// + [Fact] + public async Task GetMgGraphAccessTokenAsync_WhenPowerShellBrowserAuthFails_RetriesWithDeviceCode() + { + // Arrange + var tenantId = "12345678-1234-1234-1234-123456789abc"; + var scopes = new[] { "User.Read" }; + var deviceCodeToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZXZpY2VDb2RlIn0.signature"; + var browserFailureError = "InteractiveBrowserCredential authentication failed: user cancelled"; + + // First call (browser auth) fails with the embedded-terminal error; second call (device code) succeeds. + var callCount = 0; + _executor.ExecuteWithStreamingAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()) + .Returns(_ => + { + callCount++; + return callCount == 1 + ? Task.FromResult(new CommandResult { ExitCode = 1, StandardOutput = string.Empty, StandardError = browserFailureError }) + : Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = deviceCodeToken, StandardError = string.Empty }); + }); + + var provider = new MicrosoftGraphTokenProvider(_executor, _logger) + { + MsalTokenAcquirerOverride = (_, _, _, _) => Task.FromResult(null) + }; + + // Act + var token = await provider.GetMgGraphAccessTokenAsync(tenantId, scopes, useDeviceCode: false); + + // Assert + token.Should().Be(deviceCodeToken, + because: "when PowerShell browser auth fails with 'InteractiveBrowserCredential authentication failed' " + + "(embedded terminal), the CLI must automatically retry with device code flow"); + callCount.Should().Be(2, + because: "browser auth attempt (1) should be followed by a device-code retry attempt (2)"); + + // The second call must include -UseDeviceCode + await _executor.Received(1).ExecuteWithStreamingAsync( + Arg.Any(), + Arg.Is(args => args.Contains("-UseDeviceCode")), + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any()); + } + + [Theory] + [InlineData("InteractiveBrowserCredential authentication failed")] + [InlineData("INTERACTIVEBROWSERCREDENTIAL AUTHENTICATION FAILED: user cancelled")] // case-insensitive + public async Task GetMgGraphAccessTokenAsync_WhenPowerShellBrowserAuthFails_DeviceCodeRetryIsCaseInsensitive(string stderr) + { + // Arrange — ensures IsInteractiveBrowserFailure uses OrdinalIgnoreCase as documented + var tenantId = "12345678-1234-1234-1234-123456789abc"; + var scopes = new[] { "User.Read" }; + var deviceCodeToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZXZpY2VDb2RlIn0.signature"; + + var callCount = 0; + _executor.ExecuteWithStreamingAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()) + .Returns(_ => + { + callCount++; + return callCount == 1 + ? Task.FromResult(new CommandResult { ExitCode = 1, StandardOutput = string.Empty, StandardError = stderr }) + : Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = deviceCodeToken, StandardError = string.Empty }); + }); + + var provider = new MicrosoftGraphTokenProvider(_executor, _logger) + { + MsalTokenAcquirerOverride = (_, _, _, _) => Task.FromResult(null) + }; + + // Act + var token = await provider.GetMgGraphAccessTokenAsync(tenantId, scopes, useDeviceCode: false); + + // Assert + token.Should().Be(deviceCodeToken, + because: "IsInteractiveBrowserFailure must match the error string case-insensitively"); + } + + [Fact] + public async Task GetMgGraphAccessTokenAsync_WhenUseDeviceCodeAlreadyTrue_DoesNotRetryAgain() + { + // Arrange — ensures no double-retry when the caller already requested device code + var tenantId = "12345678-1234-1234-1234-123456789abc"; + var scopes = new[] { "User.Read" }; + var browserFailureError = "InteractiveBrowserCredential authentication failed"; + + _executor.ExecuteWithStreamingAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new CommandResult { ExitCode = 1, StandardOutput = string.Empty, StandardError = browserFailureError })); + + var provider = new MicrosoftGraphTokenProvider(_executor, _logger) + { + MsalTokenAcquirerOverride = (_, _, _, _) => Task.FromResult(null) + }; + + // Act — caller already set useDeviceCode: true + var token = await provider.GetMgGraphAccessTokenAsync(tenantId, scopes, useDeviceCode: true); + + // Assert + token.Should().BeNull( + because: "when useDeviceCode is already true the retry guard (!useDeviceCode) prevents an infinite loop"); + // Only one PowerShell call — no retry + await _executor.Received(1).ExecuteWithStreamingAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()); + } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs index 1a6dc6be..661c1202 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs @@ -52,11 +52,11 @@ public async Task CheckAsync_ShouldLogMainWarningMessage() // Act await check.CheckAsync(config, _mockLogger); - // Assert — [WARN] prefix is included in the message (logged at Warning severity → yellow in formatter) + // Assert — warning output is logged at Warning severity (yellow color, no WARNING: text prefix from formatter) _mockLogger.Received().Log( LogLevel.Warning, Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("[WARN] Frontier Preview Program")), + Arg.Is(o => o.ToString()!.Contains("Warn: Frontier Preview Program")), Arg.Any(), Arg.Any>()); }