Add a365 network gsa enable|disable|status - #497
Lala Sushant Srivastava (lasrivas) wants to merge 9 commits into
Conversation
The documented subnet-injection flow ends with Enable-SubnetInjection, which takes the id of the Power Platform environment to link. Agent 365 provisions a managed environment per tenant and does not publish its id, so admins cannot finish the flow. These subcommands replace that final step: the platform resolves the environment server-side and performs the link. The policy systemId read stays here rather than in the platform. It is a plain ARM GET against a resource the admin already owns, and doing it client-side with the admin's own az login avoids giving the platform a delegated ARM consent grant it otherwise has no need for. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Global Secure Access is a per-environment Power Platform setting, and Agent 365 does not publish the id of the managed environment it provisions, so the admin surfaces that take an environment id cannot reach it. The platform resolves the environment and applies the change; these commands carry no environment identifier at all. Two things that are not obvious from the diff: Power Platform applies the change asynchronously but issues no operation id for it, so unlike vnet there is no handle to poll. The CLI converges by re-reading the setting, which is why status takes no --operation-id. NotConfigured is reported distinctly from Disabled. A tenant that has never set the value has not turned it off, and the distinction changes what an admin should do next. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GsaService asked for a token with a login hint but no tenant, so the authority stayed `common`. The Windows broker ignores the hint in that case and returns whichever account Windows prefers; the resulting UPN mismatch is only logged at Debug, so a tenant-wide setting could be applied to the wrong tenant without any visible warning. Passing the tenant also arms the existing mismatch self-heal in AuthenticationService, which is inert while tenantId is null. Resolve both tenant and user from a single `az account show` via IAzureCliService - the same source `vnet link` already uses - rather than adding a --tenant-id option the user would have to keep in sync with their az context. No az login, or an account with no tenant, now fails with a clear message instead of silently guessing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved tenant-targeting, cancellation, validation, and VNet polling issues remain.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds tenant-level GSA enable, disable, and status commands alongside supporting VNet networking functionality.
Changes:
- Adds GSA services, models, authentication, and convergence polling.
- Registers network commands, handlers, and dependency injection.
- Adds tests, documentation, and release notes.
File summaries
| File | Reviewed change |
|---|---|
src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VNetLinkServiceTests.cs |
VNet service tests |
src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GsaServiceTests.cs |
GSA service tests |
src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs |
ARM policy lookup tests |
src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NetworkCommandTests.cs |
Network command tests |
src/Microsoft.Agents.A365.DevTools.Cli/Services/VNetLinkService.cs |
VNet operations and polling |
src/Microsoft.Agents.A365.DevTools.Cli/Services/IVNetLinkService.cs |
VNet service contract |
src/Microsoft.Agents.A365.DevTools.Cli/Services/IGsaService.cs |
GSA service contract |
src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs |
GSA API operations and polling |
src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs |
Enterprise policy resolution |
src/Microsoft.Agents.A365.DevTools.Cli/Program.cs |
Command and service registration |
src/Microsoft.Agents.A365.DevTools.Cli/Models/VNetModels.cs |
VNet models |
src/Microsoft.Agents.A365.DevTools.Cli/Models/GsaModels.cs |
GSA response models |
src/Microsoft.Agents.A365.DevTools.Cli/Constants/CommandNames.cs |
Network command constant |
src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs |
Network, VNet, and GSA command tree |
docs/commands/README.md |
Command index entries |
docs/commands/network.md |
VNet command documentation |
docs/commands/network-gsa.md |
GSA command documentation |
CHANGELOG.md |
Release notes |
Review details
Suppressed comments (7)
src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs:260
- The new enable/disable handlers are not exercised through
Command.InvokeAsync; the tests only parsegsa enableand callReportGsaAsyncdirectly. A wiring regression could leaveSetAsyncuncalled or lose the handler's exit-code propagation while all tests stay green; add invocation tests for both verbs covering service calls and success/failure results.
var result = await gsaService.SetAsync(enabled, ct);
context.ExitCode = await ReportGsaAsync(logger, gsaService, result, wait, enabled, ct);
src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs:287
- The new GSA status handler is also only covered by parse/tree assertions; no test invokes it to verify that
GetStatusAsyncis called and that null responses produce exit code 1. Add an invocation test for the success and request-error branches so handler wiring is covered, not justLogGsaStatus/ReportGsaAsync.
var status = await gsaService.GetStatusAsync(ct);
if (status == null)
{
context.ExitCode = 1;
return;
}
LogGsaStatus(logger, status);
context.ExitCode = 0;
src/Microsoft.Agents.A365.DevTools.Cli/Commands/NetworkCommand.cs:111
--tenant-idis anOption<string?>; an explicitly supplied empty or whitespace value enters this branch and is silently replaced with the currentaztenant. That makes a malformed explicit target indistinguishable from omission and can apply a tenant-wide link to the wrong tenant. Detect whether the option was supplied and reject blank values with a targeted error before falling back.
if (string.IsNullOrWhiteSpace(tenantId))
{
var account = await azureCliService.GetCurrentAccountAsync();
tenantId = account?.TenantId;
if (string.IsNullOrWhiteSpace(tenantId))
src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs:328
- When the command token is cancelled during the policy GET or response read, RetryHelper rethrows the cancellation, but this catch converts it to
null.LinkAsyncthen reports an ordinary policy-resolution failure instead of honoring Ctrl+C; rethrowOperationCanceledExceptionwhenctis cancelled before the catch-all.
catch (Exception ex)
{
if (NetworkHelper.IsConnectionResetByProxy(ex))
_logger.LogWarning(NetworkHelper.ConnectionResetWarning);
else
src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs:276
policyArmIdis CLI-controlled and is concatenated directly into the ARM URL after only a whitespace check. A value containing query, fragment, or path-traversal syntax can alter the resource orapi-versionportion of the request; validate it as a well-formed/subscriptions/.../providers/Microsoft.PowerPlatform/enterprisePolicies/...resource ID before constructing this URL.
var url = $"{ArmBaseUrl}{policyArmId}?api-version={apiVersion}";
src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs:269
tenantIdis a required non-nullable parameter, but it is passed toEnsureArmHeadersAsyncwithout a whitespace guard. A blank value can fall through to common-tenant authentication and defeat the tenant-targeted ARM read; reject it before the first use.
string tenantId,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(policyArmId))
throw new ArgumentException("Policy ARM id is required.", nameof(policyArmId));
if (!await EnsureArmHeadersAsync(tenantId, ct))
src/Microsoft.Agents.A365.DevTools.Cli/Services/GsaService.cs:120
- The account lookup validates only
TenantId; anaz account showresult with a missing user name is passed as an emptyuserId, which disables the user hint and lets the broker select another cached account. For a tenant-wide setting, reject a missing user name before token acquisition so the selected tenant and user are both enforced.
if (account is null || string.IsNullOrWhiteSpace(account.TenantId))
{
_logger.LogError("Could not determine your Azure tenant. Run 'az login' and try again.");
return null;
- Files reviewed: 18/18 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Rick Brighenti (rbrighenti)
left a comment
There was a problem hiding this comment.
Requesting changes. The ARM URL construction lets the bearer token be sent to an arbitrary host and needs fixing before merge.
The PR also adds network vnet link/unlink/status and ARM enterprise policy resolution, which aren't in the title. Please retitle, or split the VNet part out, since most of the findings are there.
Carries the vnet fixes from #494 (this branch contains that change set) and applies the same treatment to the gsa subcommands. Pin the enterprise-policy ARM id to its expected shape before concatenating it onto the ARM base URL. The base has no trailing slash and the ARM bearer token is a default request header, so `--policy-arm-id "@evil.example/x"` produced `https://management.azure.com@evil.example/x` -- userinfo, not host -- and sent the token to the attacker. Also: - Treat `NotStarted` as in-flight, matching what network.md documents. - Acquire the Agent 365 token for the resolved tenant rather than the signed-in default. `vnet unlink` and `vnet status` gain `--tenant-id` so they can do the same; the gsa commands already resolve the az-login tenant themselves. - Confirm before `vnet link --swap`, `vnet unlink`, `gsa enable` and `gsa disable`, with `--yes` for automation. Plain `vnet link` is not gated: a different existing link is reported as a conflict rather than replaced. - Reject an explicitly blank `--tenant-id` instead of silently falling back. - Use `CommandNames.Network` rather than a literal. - Drive every handler through `InvokeAsync` in tests. The previous doc comment claimed `ReportAsync` covered them, but tenant resolution, confirmation, service calls and exit codes were untested. - Stop the VNet tests shelling out to `az account show` via `AzCliHelper`'s static cache, using the repo's existing `loginHintResolver` seam. - Trim the CHANGELOG entries and reference #494 and #497. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Moderate correctness and timeout/cancellation issues remain unresolved, along with changelog nits.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (3)
Resolved since last review (5)
This token request omits the tenant that the command selected for the ARM read, so…network.mddocumentsNotStartedas an in-flight status, but this predicate only treats… This only checks thatgsa enable --waitparses; none of the three newSetHandlerblocks is…CommandNames.Networkwas added for centralized command names, but this registration still… This Unreleased entry is several sentences and includes implementation details and rationale; it…
…ogin prerequisite The --wait ceiling only bounded the gap between completed polls, so a poll starting just inside the budget could run to the HttpClient's own timeout and overshoot by minutes. The ceiling is now armed on the token each request is made with; a timeout mid-request reports the last known state, while a caller's Ctrl+C still propagates. ArmApiService's broad catch swallowed the OperationCanceledException that RetryHelper deliberately rethrows, so Ctrl+C during the policy read surfaced as "could not read the policy" and link carried on as though the policy did not exist. Both test helpers configured GetAccessTokenAsync without a matcher for its 8th parameter, the CancellationToken, pinning the setup to ct == default. Any call carrying a real token missed the setup and returned null, which the services report as a failed token acquisition -- so a test could not exercise any cancellation path at all. This is why the two new cancellation tests initially failed for the wrong reason. docs: the az login prerequisite claimed it was used "only to read the enterprise policy". It is actually the source of two defaults, the tenant and the signed-in account, and --tenant-id overrides only the first. Tokens are never borrowed from Azure CLI -- both the ARM read and the Agent 365 call acquire their own. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Every convergence poll re-entered SendAsync, which shells out to `az account show` and returns null on any CLI hiccup. A transient failure part-way through --wait therefore aborted with "could not determine your Azure tenant" even though the tenant had been resolved successfully moments earlier. The account is now resolved once and reused; a failed resolution is not cached, so an admin who runs `az login` after the first attempt does not have to restart the process. WaitForStatusAsync had the same unbounded in-flight poll as the vnet wait: the stopwatch only bounded the gap between completed polls, so a poll starting inside the budget could run to the HttpClient's timeout. The ceiling is now armed on the request token, and a caller's Ctrl+C is still distinguished from a timeout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Reuse one Azure CLI account snapshot for confirmation and authentication to prevent tenant mismatches and transient failures.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (2)
This branch was cut to include PR #494's virtual network work, so the two PRs duplicated roughly 1400 lines and every VNet review finding had to be answered twice. GSA never depended on any of it: GsaService talks to the platform directly and shares only the tenant resolution and confirmation helpers in NetworkCommand, which both features need. Removed VNetLinkService, IVNetLinkService, VNetModels and their tests, reverted ArmApiService and its tests to main, dropped the vnet subcommand tree along with ReportAsync and LogStatus, and removed the vnet entries from the CHANGELOG and the docs index. NetworkCommand.CreateCommand and its test helper lose the IVNetLinkService parameter. The two branches now both define the network root command and the shared helpers, so whichever merges second will conflict there. That is a smaller price than reviewing the same code on two PRs. 2063 passed, 0 failed, 12 skipped.
|
The virtual network change set has been removed from this PR, so it now carries only the GSA work. This branch was originally cut to include #494, which meant roughly 1400 lines appeared on both PRs and every VNet review finding had to be answered twice. GSA never depended on any of it: Removed in d923c2a: Review the VNet code on #494 instead; it is unchanged there. One consequence worth flagging: both branches now define the 2063 passed, 0 failed, 12 skipped. |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Several moderate service reliability and error-handling issues remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (5)
Avoid duplicate account lookup causing failures or tenant mismatch Reuse account snapshot to prevent applying settings to the wrong tenant Validate tenant before caching Azure account · New Handle HttpClient timeouts as documented failures · New Correct inaccurate Azure CLI account lookup prerequisites · New
The set handler read az account show to name the tenant in the confirmation prompt, and GsaService read it again to pick the tenant it authenticated against. az account show reflects mutable local state, so the two reads could disagree: the command could confirm tenant A and apply the tenant-wide setting to tenant B, and a transient CLI failure between them could fail a command whose tenant had already been resolved. GsaService is now told which account to act as. It no longer depends on IAzureCliService, and the account cache goes with it. That cache had its own bug: ??= stored any non-null account before its tenant was validated, and AzureAccountInfo defaults TenantId to string.Empty, so a blank-tenant account was cached permanently and never retried after a later az login. SendAsync also rethrew every OperationCanceledException. HttpClients own timeout surfaces as one with no token cancelled, so a bare enable, disable or status threw at the caller instead of returning the documented null. It now rethrows only when the supplied token is actually cancelled, which still covers both a Ctrl+C and the wait ceiling firing on its linked token. Also fixed the network-gsa.md prerequisite, which claimed nothing is read from Azure and repeated half a sentence after an earlier edit. 2065 passed, 0 failed, 12 skipped.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved critical and moderate service-handling issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (1)
Resolved since last review (5)
CreateAuthenticatedClient built the client with HttpClient's default handler ownership, so a caller that holds one handler and builds a client per request lost the handler to the first client's disposal. GsaService is exactly that shape, so the second poll of WaitForStatusAsync would have failed with ObjectDisposedException against any handler that honours Dispose. A supplied handler now stays owned by whoever supplied it.
CreateAuthenticatedClient built the client with HttpClient's default handler ownership, so a caller that holds one handler and builds a client per request lost the handler to the first client's disposal. VNetLinkService is exactly that shape, so the second poll of WaitForCompletionAsync would have failed with ObjectDisposedException against any handler that honours Dispose. A supplied handler now stays owned by whoever supplied it. Found on #497, which carries the identical factory; not flagged here.
Krishnadheeraj (DheerajPannala)
left a comment
There was a problem hiding this comment.
Thanks, the account-once threading is a real fix and the GSA flow reads well. I checked the contract against the platform change this calls (MCP-Platform PR 3686, merged) and built and ran the suite on this branch and merged onto current main (builds; all tests pass). Details are inline; the summary:
Before merge
--waitcan report the old value as success (inline onNetworkCommand.cs): the platform'sstatusresponse never setspending, so after a timed-out wait the command prints the old value and exits 0 with no "still being applied" hint. The tests covering this path feedpending: trueinto status responses, which the platform does not send.- Scopes (please confirm): the platform rejects any token without the exact scope (
AgentTools.Gsa.Manage.Allfor enable/disable,AgentTools.Gsa.Read.Allfor status) with403 insufficient_scope. The token is{Agent 365 Tools app}/.defaultfrom the Azure PowerShell public client (no client id is passed), so both scopes need to be exposed on the Agent 365 Tools app and pre-authorized for that client, in every cloud the CLI targets. - Rebase onto
main: needed for #478 (cloud-aware endpoints; inline onGsaService.cs). Also, this branch still carries #494's original commit1318bd50plusd923c2a, which deletes those files. A squash merge nets that out, but with a merge commit (enabled in this repo) the two branches share1318bd50as a common ancestor, so whichever merges second hits modify/delete conflicts on the VNet files, and resolving them toward this branch deletes the VNet feature. Rebasing ontomain(ideally dropping that pair) removes the shared ancestor. - Interaction with #494: with squash merges, whichever lands second conflicts in
NetworkCommand.cs,Program.cs,NetworkCommandTests.cs,docs/commands/README.mdandCHANGELOG.md(reproduced in both orders). The semantics also diverge under onenetworkparent:vnethas--tenant-idand does not confirm a plainlink;gsahas no--tenant-idand confirms both verbs.
Verified and looks right
- Resolving the
azaccount once and passing it down removes the prompt/token tenant split, and the tests pin it. - HttpClient's own timeout returns the documented
null, while a caller's cancellation and the wait ceiling still propagate. disposeHandler: falseinHttpClientFactoryis the right ownership model.- Routes and shapes match the platform (
POST /agents/gsa/enable|disable,GET /agents/gsa/status;status/pending/reason;error), andNotConfiguredstays distinct fromDisabled.
Nit: Program.cs creates the logger with the literal "network", and CommandNames.Network exists.
|
|
||
| // Still pending is not a failure. The platform accepted the change and the environment | ||
| // will catch up; reporting non-zero here would break scripts that chain on success. | ||
| if (result.Pending) |
There was a problem hiding this comment.
Medium-High: after WaitForStatusAsync, result comes from GET /agents/gsa/status, and the platform sets pending only on the 202 from enable/disable, never on status. So when the wait times out, result.Pending is false: this prints e.g. "Global Secure Access: Disabled", skips this hint, and exits 0, right after the admin ran enable --wait. For a security control that reads as "done".
Suggest comparing against the requested value once the wait is over, e.g.:
if (wait && !string.Equals(result.Status, expectedStatus, StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning(
"Global Secure Access is not {Expected} yet; the change is still being applied. Check with: a365 network gsa status",
expectedStatus);
return 1;
}The exit code is your call given the "pending is not a failure" principle, but with --wait the caller explicitly asked to block until the value applies.
| } | ||
|
|
||
| [Fact] | ||
| public async Task ReportGsaAsync_WhenStillPendingAfterWaiting_ReturnsSuccess() |
There was a problem hiding this comment.
Medium (test): this stubs WaitForStatusAsync to return Pending = true, but that result is a status read, which never carries pending from the platform. The realistic timed-out result for an enable is { Status = "Disabled", Pending = false }, and with that input ReportGsaAsync currently exits 0 with no "still being applied" message. Suggest switching the stub to the realistic shape and asserting that the timeout is reported (see the comment on NetworkCommand.cs).
| handler.QueueResponse(StatusResponse(HttpStatusCode.OK, "Disabled", pending: true)); | ||
| var svc = CreateService(handler); | ||
|
|
||
| // Shorter than the poll interval, so the first non-matching read is also the last. |
There was a problem hiding this comment.
Low (test): the response queued just above is a status read with pending: true, which the platform does not send (pending appears only on the 202 from enable/disable). The test still proves the budget logic, but the fixture teaches the wrong contract; pending: false (and asserting Pending is false) keeps it honest.
| return last; | ||
| } | ||
|
|
||
| if (last == null || string.Equals(last.Status, expectedStatus, StringComparison.OrdinalIgnoreCase)) |
There was a problem hiding this comment.
Medium: one failed poll (null: any 5xx, gateway error or network blip) ends the wait, and ReportGsaAsync exits 1 even though the platform already accepted the change. That is the failure mode the account-once change set out to remove ("a transient failure part-way through a wait does not abort a change that already succeeded"). Suggest tolerating a few consecutive failed reads before giving up, and, when giving up, saying that the change was accepted and how to check on it.
| // hint names a different one — so a tenant-wide setting would be changed on the wrong | ||
| // tenant. Passing the tenant also arms the mismatch self-heal in AuthenticationService. | ||
| var authToken = await _authService.GetAccessTokenAsync( | ||
| audience, account.TenantId, userId: account.User.Name, ct: cancellationToken); |
There was a problem hiding this comment.
Medium (after rebase): since #478, every Agent 365 Tools call in Agent365ToolingService passes authorityHost (resolved from the configured environment). This call does not, so it always authenticates against the commercial cloud. The docs say "Public cloud only", but nothing enforces it, so a gcc/gcc-high/dod configuration would get a commercial token for a sovereign endpoint. Either thread authorityHost through like the other services or fail fast outside the commercial cloud.
Also on main: BuildBaseUrl below duplicates Agent365ToolingService.BuildAgent365ToolsBaseUrl (#494's VNetLinkService has a third copy), and ConfigConstants.GetAgent365ToolsOrigin(environment) now exists for exactly this.
Related (tests): GetAccessTokenAsync on main has a 9th optional parameter, authorityHost. FakeAuth in GsaServiceTests matches 8, which pins authorityHost == null, the same shape as the CancellationToken pinning fixed earlier. Once this call passes authorityHost, the setup stops matching: the success-path tests fail loudly, but the tests that expect null pass without exercising anything.
| } | ||
|
|
||
| return await confirmationProvider.ConfirmAsync( | ||
| $"{action} for tenant {tenantId}. This changes networking for every Agent 365 agent in the tenant. Continue?"); |
There was a problem hiding this comment.
Low: two small prompt issues:
- Every other confirmation in the CLI ends with a
(y/N):or[y/N]:hint (e.g.CleanupCommand,ClientAppValidator,PermissionsSubcommand). This one ends withContinue?and no trailing space. - The account is already resolved when this runs, so the prompt could name who is acting as well as the tenant id (e.g.
... for tenant {tenantId} as {account.User.Name}), which is easier for an admin to recognize than a GUID.
| } | ||
| } | ||
|
|
||
| LogGsaStatus(logger, result); |
There was a problem hiding this comment.
Low (UX): on a 202 the platform returns the value it has not displaced yet, so enable without --wait prints "Enabling Global Secure Access..." followed by "Global Secure Access: Disabled". The hint follows, but the first thing the admin reads is the old value. Suggest labelling both, e.g. "Requested: Enabled (still being applied). Current: Disabled."
|
|
||
| - **Global Administrator** or **Power Platform Administrator** in the tenant. The platform rejects | ||
| anyone else. | ||
| - An `az login` to the tenant you intend to configure. |
There was a problem hiding this comment.
Low (docs, optional design):
- Tenants that only have Microsoft 365 (no Azure subscription) need
az login --allow-no-subscriptions; otherwiseaz loginfails before these commands can read the account. Worth stating here, since a GSA admin may never have used Azure. - These commands read no Azure resources;
azonly supplies the tenant and the login hint. With an explicit tenant the token request is pinned to that tenant (andAuthenticationService'stidcheck enforces it), so a--tenant-idlikevnethas would not reintroduce the prompt/token mismatch, and it would remove the Azure CLI dependency for admins who never use Azure.
| **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. | ||
|
|
||
| ### Added | ||
| - `a365 network gsa enable|disable|status` — turns Global Secure Access on or off for the tenant's Agent 365 environment. `NotConfigured` is reported distinctly from `Disabled`, because a tenant that has never set the value has not turned it off. Requires Global Administrator or Power Platform Administrator. See [docs/commands/network-gsa.md](docs/commands/network-gsa.md) (#497). |
There was a problem hiding this comment.
Nit: on the question in the earlier thread: the one-sentence rule is written down in .github/copilot-instructions.md ("CHANGELOG": "Each entry is one crisp consumer-facing sentence") and in CLAUDE.md, and the section ships verbatim to nuget.org. This entry is four sentences including rationale, and no other entry uses a relative link (it will not resolve outside GitHub). Suggestion: "a365 network gsa enable|disable|status turns Global Secure Access on or off for your Agent 365 environment (#497)." Agreed that the older multi-sentence entries deserve a separate cleanup.
| | [develop-mcp list-servers](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-list-servers) | List MCP servers in a specific Dataverse environment. | | ||
| | [develop-mcp publish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-publish) | Publish an MCP server to a Dataverse environment. | | ||
| | [develop-mcp unpublish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-unpublish) | Unpublish an MCP server from a Dataverse environment. | | ||
| | [network](network-gsa.md) | Configure tenant networking for Agent 365. | |
There was a problem hiding this comment.
Low: #494 adds its own | [network](network.md) | Configure tenant networking for Agent 365. | row, so after both merge the index has two identical network rows pointing at different pages. Consider a single network row (or a small landing page) plus the per-subcommand rows.
| return string.IsNullOrWhiteSpace(body) | ||
| ? new GsaStatusResponse() | ||
| : JsonSerializer.Deserialize<GsaStatusResponse>(body); |



Adds
a365 network gsa enable|disable|status, which turns Global Secure Access on or off for the tenant's Agent 365 environment.Global Secure Access is a per-environment Power Platform setting, so configuring it normally needs the environment's id. Agent 365 does not publish that id, so the CLI asks the platform to apply the setting to the environment it resolves for the tenant. The platform half shipped in bic/MCP-Platform#3686.
Design
docs/superpowers/specs/2026-09-15-gsa-environment-setting-design.md.Notable decisions
NotConfiguredis reported distinctly fromDisabled. A tenant that has never set the value has not turned it off, and the distinction changes what an admin does next, sostatussays so in as many words.--waitpolls for the requested value, with a ten-minute ceiling armed on the request token so an in-flight poll cannot overshoot it.--tenant-id. The commands authenticate against the tenant of your currentaz login. The command resolves that account once and hands it to the service, so--waitcannot confirm one tenant and change another, and a transientazfailure part-way through a wait does not abort a change that already succeeded.enableanddisableprompt before applying, naming the tenant, and take--yesto skip.statusis read-only and does not prompt.Scope
This PR no longer carries the virtual network change set. It was originally branched to include #494's work, which duplicated roughly 1400 lines across the two PRs and meant every VNet review finding had to be answered twice. GSA never depended on it —
GsaServicetalks to the platform directly and shares only the tenant-resolution and confirmation helpers inNetworkCommand. Removed in d923c2a.Both branches now define the
networkroot command and those shared helpers, so whichever merges second will conflict there. That is a smaller price than reviewing the same code twice.Review rounds
Three rounds of review are folded in. The first (16ef241) covered ARM-shape validation,
NotStartedas in-flight, tenant-targeted tokens,--yesconfirmation onenableanddisable, handler-body test coverage and the CHANGELOG entry. The second (71a6570, ae03dfe) armed the--waitceiling on the request token and corrected theaz loginprerequisite in the docs.Third round (94e71aa, a15fac1)
az account showto name the tenant in the confirmation prompt, thenGsaServiceresolved it again to authenticate. Anaz account setbetween the two, or any mutation of CLI state mid-run, meant confirming tenant A and changing tenant B.IGsaServicenow takes the resolvedAzureAccountInfoas its first parameter, which also let the service drop itsIAzureCliServicedependency and its account cache -- that cache was itself unsound, becauseAzureAccountInfo.TenantIddefaults tostring.Emptyrather than null, so a??=cached a blank-tenant account forever instead of retrying.SendAsyncno longer rethrows HttpClient's own timeout as cancellation. It surfaces as anOperationCanceledExceptionwith no token cancelled, so a bareenable,disableorstatusthrew at the caller instead of returning the documented null and logging the failure. Now rethrows only when the supplied token is actually cancelled, which still covers Ctrl+C and the wait ceiling firing on its linked token.HttpClientno longer disposes a handler it does not own.HttpClientFactory.CreateAuthenticatedClientbuilt the client asnew HttpClient(handler), which defaults todisposeHandler: true, so a service that holds one handler as a field and builds a client per request lost that handler to the first client's disposal. Every later request, including every poll after the first, would have failed withObjectDisposedExceptionagainst any handler with real disposal semantics. A supplied handler now stays owned by the supplier.docs/commands/network-gsa.mdprerequisite paragraph rewritten; the split in d923c2a had left a duplicated fragment mid-sentence.Tests
Full suite: 2068 passed, 0 failed, 12 skipped.