Skip to content

Commit bba32af

Browse files
committed
Address review comments on PR #418
Grant flagged two real issues in the #412 follow-up. Failing tests written first, then fixes; both tests now green. - DevelopCommand: --dry-run short-circuited before the new --project-path typo-guard, so `--project-path /typo --dry-run` silently exited 0 — exactly the failure the guard was meant to prevent. Moved the path resolution and typo-guard above the dry-run block so dry-run validates --project-path the same way real mode does. Dry-run output now also includes the resolved manifest path in the "Would update" line. - CleanupCommand.BuildBootstrapConfigForCleanupAsync (the fallback used when resolver == null) was a near-duplicate of the resolver copy and still carried the global-generated-config fallback that the original PR removed. Removed the global fallback in the duplicate too. Added a TODO to collapse the duplication in a follow-up. New regression tests: - AddMcpServers_DryRunWithBogusProjectPath_ReturnsExitCode1 — invocation test that fails today (exit 0) and passes after the fix (exit 1, no dir created). - CleanupCommand_BuildBootstrapConfigForCleanupAsync_DoesNotInheritResourceIdsFromOutsideCwd — reflection-based unit test against the private static duplicate. Fails today (poisoned global resource IDs leak through), passes after the fix. If a future PR deletes the duplicate, the BindingFlags lookup will fail loudly and the test can be removed alongside. Full suite: 1436 passed / 0 failed / 12 skipped. Manual verify: typo'd --project-path with --dry-run now exits 1 with a clear directory-does-not-exist error and creates nothing on disk.
1 parent 516f43e commit bba32af

4 files changed

Lines changed: 135 additions & 17 deletions

File tree

src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,9 +1362,12 @@ private static void PrintOrphanSummary(
13621362
// Step 4: Load generated config.
13631363
// Only take agentRegistrationId from the file when the blueprint IDs match,
13641364
// confirming the file belongs to this agent.
1365-
var localGeneratedPath = Path.Combine(Environment.CurrentDirectory, "a365.generated.config.json");
1366-
var globalGeneratedPath = Path.Combine(ConfigService.GetGlobalConfigDirectory(), "a365.generated.config.json");
1367-
var generatedConfigPath = File.Exists(localGeneratedPath) ? localGeneratedPath : globalGeneratedPath;
1365+
// Project-local only — the global-directory fallback was removed because it could
1366+
// pull resource IDs from a different project's leftover state. Mirrors the same
1367+
// policy applied to BootstrapConfigResolver.BuildBootstrapConfigForCleanupAsync.
1368+
// TODO: this method is a near-duplicate of the resolver copy; collapse into a single
1369+
// shared helper in a follow-up PR.
1370+
var generatedConfigPath = Path.Combine(Environment.CurrentDirectory, "a365.generated.config.json");
13681371

13691372
string? agentRegistrationId = null;
13701373
string? agenticAppId = null;

src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopCommand.cs

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -501,20 +501,11 @@ private static Command CreateAddMcpServersSubcommand(ILogger logger, IConfigServ
501501
return;
502502
}
503503

504-
// Dry run mode
505-
if (dryRun)
506-
{
507-
logger.LogInformation("[DRY RUN] Would add the following MCP servers to configuration:");
508-
foreach (var serverName in servers)
509-
{
510-
logger.LogInformation("[DRY RUN] - {Server}", serverName);
511-
}
512-
logger.LogInformation("[DRY RUN] Would update {FileName}", McpConstants.ToolingManifestFileName);
513-
await Task.CompletedTask;
514-
return;
515-
}
516-
517-
// Resolve manifest path without requiring a365.config.json
504+
// Resolve manifest path without requiring a365.config.json.
505+
// Resolution and the --project-path typo-guard MUST run before the --dry-run
506+
// short-circuit below: dry-run is a preview of what the real command would do,
507+
// so a typo'd --project-path must fail dry-run too — otherwise users get a
508+
// misleading exit-0 preview and a confusing exit-1 when they run for real.
518509
var manifestPath = await ResolveToolingManifestPath(projectPath, configService, logger);
519510

520511
if (!File.Exists(manifestPath))
@@ -533,6 +524,20 @@ private static Command CreateAddMcpServersSubcommand(ILogger logger, IConfigServ
533524
McpConstants.ToolingManifestFileName, manifestPath);
534525
}
535526

527+
// Dry run mode
528+
if (dryRun)
529+
{
530+
logger.LogInformation("[DRY RUN] Would add the following MCP servers to configuration:");
531+
foreach (var serverName in servers)
532+
{
533+
logger.LogInformation("[DRY RUN] - {Server}", serverName);
534+
}
535+
logger.LogInformation("[DRY RUN] Would update {FileName} at {Path}",
536+
McpConstants.ToolingManifestFileName, manifestPath);
537+
await Task.CompletedTask;
538+
return;
539+
}
540+
536541
try
537542
{
538543
// Read existing manifest if present

src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddMcpServersMissingManifestTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,28 @@ public async Task AddMcpServers_DryRunWithNoManifest_DoesNotCreateManifest()
199199
.Should().BeFalse(because: "--dry-run must never write to disk");
200200
}
201201

202+
[Fact]
203+
public async Task AddMcpServers_DryRunWithBogusProjectPath_ReturnsExitCode1()
204+
{
205+
// DevelopCommand.cs:504-515 BEFORE the --project-path typo-guard at lines
206+
// 520-530. A typo'd --project-path X --dry-run silently exits 0 — exactly
207+
// the failure the guard is supposed to prevent.
208+
var bogusDir = Path.Combine(Path.GetTempPath(),
209+
"a365-issue412-doesnotexist-dryrun-" + Guid.NewGuid().ToString("N"));
210+
Directory.Exists(bogusDir).Should().BeFalse(because: "the test pre-condition is a typo'd path");
211+
212+
var exitCode = await BuildRoot().InvokeAsync(new[]
213+
{
214+
"develop", "add-mcp-servers", "mcp_M365Copilot",
215+
"--project-path", bogusDir, "--dry-run"
216+
});
217+
218+
exitCode.Should().Be(1,
219+
because: "--dry-run must validate --project-path the same way real-mode does; otherwise dry-run gives users false confidence that their command would succeed when run for real");
220+
Directory.Exists(bogusDir).Should().BeFalse(
221+
because: "--dry-run must never create directories, even by accident");
222+
}
223+
202224
[Fact]
203225
public async Task AddMcpServers_ExistingManifest_UpsertsServer()
204226
{

src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GlobalConfigDirectoryCleanupTests.cs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.Reflection;
45
using FluentAssertions;
6+
using Microsoft.Agents.A365.DevTools.Cli.Commands;
57
using Microsoft.Agents.A365.DevTools.Cli.Constants;
68
using Microsoft.Agents.A365.DevTools.Cli.Models;
79
using Microsoft.Agents.A365.DevTools.Cli.Services;
@@ -207,4 +209,90 @@ await File.WriteAllTextAsync(globalGeneratedPath, $$"""
207209
result.AgentBlueprintServicePrincipalObjectId.Should().BeNull(
208210
because: "AgentBlueprintServicePrincipalObjectId must not leak in from outside CWD");
209211
}
212+
213+
[Fact]
214+
public async Task CleanupCommand_BuildBootstrapConfigForCleanupAsync_DoesNotInheritResourceIdsFromOutsideCwd()
215+
{
216+
// The global-fallback pattern the PR removed from
217+
// BootstrapConfigResolver.BuildBootstrapConfigForCleanupAsync still exists as
218+
// a near-identical duplicate in CleanupCommand.BuildBootstrapConfigForCleanupAsync
219+
// (the fallback path used when resolver == null). The original PR fixed one of
220+
// two copies; this test pins the same invariant on the duplicate so the fix
221+
// can land symmetrically (or the duplicate can be deleted, in which case this
222+
// test will fail with a clear "method not found" and can be removed too).
223+
//
224+
// Uses reflection because the method is `private static`. If the production code
225+
// is refactored to delete or rename the method, the BindingFlags lookup will
226+
// surface the change loudly rather than silently passing.
227+
var poisonedBlueprintId = Guid.NewGuid().ToString();
228+
const string poisonedRegistrationId = "REG_FROM_GLOBAL_SHOULD_NOT_LEAK_VIA_CLEANUP";
229+
var globalGeneratedDir = ResolvedGlobalConfigDir();
230+
Directory.CreateDirectory(globalGeneratedDir);
231+
var globalGeneratedPath = Path.Combine(globalGeneratedDir, "a365.generated.config.json");
232+
// Casing intentional: matches the duplicate reader's case-sensitive
233+
// SetupHelpers.GetJsonString lookups. "AgenticAppId" is PascalCase by design
234+
// — see the ResolveAsync_InCleanupMode test above for the full rationale.
235+
await File.WriteAllTextAsync(globalGeneratedPath, $$"""
236+
{
237+
"agentBlueprintId": "{{poisonedBlueprintId}}",
238+
"agentRegistrationId": "{{poisonedRegistrationId}}",
239+
"AgenticAppId": "AGENTIC_FROM_GLOBAL_SHOULD_NOT_LEAK_VIA_CLEANUP",
240+
"agentBlueprintServicePrincipalObjectId": "SP_FROM_GLOBAL_SHOULD_NOT_LEAK_VIA_CLEANUP"
241+
}
242+
""");
243+
File.Exists(Path.Combine(_localCwd, "a365.generated.config.json")).Should().BeFalse(
244+
because: "the CWD must be clean so the only available source of poison is the global file");
245+
246+
var executorLogger = Substitute.For<ILogger<CommandExecutor>>();
247+
var executor = Substitute.For<CommandExecutor>(executorLogger);
248+
executor.ExecuteAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
249+
Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
250+
.Returns(Task.FromResult(new CommandResult
251+
{
252+
ExitCode = 0, StandardOutput = "explicit-tenant", StandardError = string.Empty
253+
}));
254+
255+
var graphApiService = Substitute.ForPartsOf<GraphApiService>(
256+
Substitute.For<ILogger<GraphApiService>>(),
257+
executor,
258+
(Func<Task<string?>>)(() => Task.FromResult<string?>(null)));
259+
// The cleanup duplicate calls FindApplicationByDisplayNameAsync WITHOUT a
260+
// CancellationToken argument (see CleanupCommand.cs ~line 1356-1357), so the
261+
// 2-arg overload must be stubbed in addition to the 3-arg one for safety.
262+
graphApiService.FindApplicationByDisplayNameAsync(
263+
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
264+
.Returns(Task.FromResult<string?>(poisonedBlueprintId));
265+
graphApiService.FindApplicationByDisplayNameAsync(
266+
Arg.Any<string>(), Arg.Any<string>())
267+
.Returns(Task.FromResult<string?>(poisonedBlueprintId));
268+
269+
var cleanupLogger = Substitute.For<ILogger<CleanupCommand>>();
270+
271+
var method = typeof(CleanupCommand).GetMethod(
272+
"BuildBootstrapConfigForCleanupAsync",
273+
BindingFlags.NonPublic | BindingFlags.Static);
274+
method.Should().NotBeNull(
275+
because: "the private static fallback duplicate must exist for this regression test to verify; if a later PR collapses or deletes the duplicate, delete this test too");
276+
277+
var task = (Task<Agent365Config?>)method!.Invoke(null, new object?[]
278+
{
279+
"CleanupAgent",
280+
"explicit-tenant",
281+
executor,
282+
graphApiService,
283+
cleanupLogger
284+
})!;
285+
var result = await task;
286+
287+
result.Should().NotBeNull(
288+
because: "the fallback must still build a valid config from Entra even when no local generated file exists");
289+
result!.AgentBlueprintId.Should().Be(poisonedBlueprintId,
290+
because: "the blueprint ID is the authoritative Entra value, independent of any on-disk file");
291+
result.AgentRegistrationId.Should().BeNull(
292+
because: "AgentRegistrationId must not leak in from outside CWD via the cleanup duplicate — only the project-local generated config may be consulted");
293+
result.AgenticAppId.Should().BeNull(
294+
because: "AgenticAppId must not leak in from outside CWD via the cleanup duplicate");
295+
result.AgentBlueprintServicePrincipalObjectId.Should().BeNull(
296+
because: "AgentBlueprintServicePrincipalObjectId must not leak in from outside CWD via the cleanup duplicate");
297+
}
210298
}

0 commit comments

Comments
 (0)