Skip to content

Conversation

@JamesNK
Copy link
Member

@JamesNK JamesNK commented Jan 26, 2026

Description

Adds aspire run --isolated. Used by agents to run aspire in the background from multiple app hosts using aspire run --isolated --detach

  • Randomizes DCP ports (avoid port conflicts)
  • Copies user secrets to a new file with a randomly generated secrets ID
  • Adds a message if an Aspire instance is closed when --isolated is specified (people might expect it to allow concurrent runs from a single directory, when in reality it's designed for worktrees)
  • Fixes the CLI dashboard message. It now shows the allocated dashboard URL, not the configured one (no longer valid if randomized)

Fixes #13607
Fixes #12509

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
    • No. Follow-up changes expected.
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
    • No
  • Did you add public API?
    • Yes
      • If yes, did you have an API Review for it?
        • Yes
        • No
      • Did you add <remarks /> and <code /> elements on your triple slash comments?
        • Yes
        • No
    • No
  • Does the change make any security assumptions or guarantees?
    • Yes
      • If yes, have you done a threat model and had a security review?
        • Yes
        • No
    • No
  • Does the change require an update in our Aspire docs?

@JamesNK JamesNK requested a review from davidfowl January 26, 2026 02:07
@github-actions
Copy link
Contributor

github-actions bot commented Jan 26, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 14123

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 14123"

@github-actions
Copy link
Contributor

github-actions bot commented Jan 26, 2026

🎬 CLI E2E Test Recordings

The following terminal recordings are available for commit a362413:

Test Recording
CreateAndDeployToDockerCompose ▶️ View Recording
CreateAndDeployToDockerComposeInteractive ▶️ View Recording
CreateAndRunAspireStarterProject ▶️ View Recording
CreateAndRunJsReactProject ▶️ View Recording
CreateAndRunPythonReactProject ▶️ View Recording
CreateEmptyAppHostProject ▶️ View Recording
CreateStartAndStopAspireProject ▶️ View Recording
CreateTypeScriptAppHostWithViteApp ▶️ View Recording
DoctorCommand_WithSslCertDir_ShowsTrusted ▶️ View Recording
DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted ▶️ View Recording
PsCommandListsRunningAppHost ▶️ View Recording

📹 Recordings uploaded automatically from CI run #21395825072

@JamesNK JamesNK force-pushed the jamesnk/isolated branch 3 times, most recently from 9606930 to 6a5c50a Compare January 26, 2026 04:42
@JamesNK JamesNK marked this pull request as ready for review January 26, 2026 04:42
Copilot AI review requested due to automatic review settings January 26, 2026 04:42
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an --isolated mode to aspire run that randomizes ports and uses per-run isolated user secrets, plus supporting infrastructure to share user-secrets path logic across CLI and hosting. It also wires localized strings and tests around the new behavior.

Changes:

  • Introduced shared UserSecretsPathHelper and IsolatedUserSecretsHelper under Aspire.Shared.UserSecrets, and updated hosting code/tests to use the shared helpers instead of a pipelines-specific implementation.
  • Extended the CLI run command with a new --isolated option that sets DcpPublisher__RandomizePorts and creates/cleans up an isolated DOTNET_USER_SECRETS_ID for the current run, including new interaction messages and localized strings.
  • Added unit tests for the shared user-secrets helpers and for the new --isolated behavior in RunCommand, verifying both randomized ports and user-secrets isolation.

Reviewed changes

Copilot reviewed 28 out of 29 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/Aspire.Hosting.Tests/UserSecretsParameterDefaultTests.cs Switched tests to use the shared UserSecretsPathHelper namespace, validating user-secrets defaults against the new shared helper.
tests/Aspire.Hosting.Tests/SecretsStoreTests.cs Updated to use Aspire.Shared.UserSecrets.UserSecretsPathHelper for reading/clearing secrets in store tests, aligning with the shared helper.
tests/Aspire.Cli.Tests/Utils/IsolatedUserSecretsHelperTests.cs Added focused tests around IsolatedUserSecretsHelper creation/cleanup paths, including null/empty IDs and non-existent secrets.
tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs Added a new test that exercises run --isolated, asserting randomized ports and a distinct GUID-based DOTNET_USER_SECRETS_ID wired via the new helper.
src/Shared/UserSecrets/UserSecretsPathHelper.cs Retargeted the user-secrets path helper into Aspire.Shared.UserSecrets for reuse by both hosting and CLI.
src/Shared/UserSecrets/IsolatedUserSecretsHelper.cs Introduced a shared helper for cloning and cleaning up isolated user-secret sets by ID.
src/Aspire.Hosting/UserSecrets/UserSecretsManagerFactory.cs Updated to use the shared UserSecretsPathHelper when resolving secrets paths while retaining existing manager behavior.
src/Aspire.Hosting/Pipelines/Internal/UserSecretsPathHelper.cs Removed the hosting-specific UserSecretsPathHelper now superseded by the shared implementation.
src/Aspire.Hosting/Aspire.Hosting.csproj Linked the shared UserSecretsPathHelper into the hosting assembly and ensured internals visibility remains intact.
src/Aspire.Cli/Utils/UserSecretsHelper.cs Added a CLI-local helper that duplicates the shared isolated user-secrets functionality (currently unused and overlapping with IsolatedUserSecretsHelper).
src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hant.xlf Added localized entries for the --isolated argument description and “Copying user secrets for isolated mode...” message (pending actual translations).
src/Aspire.Cli/Resources/xlf/RunCommandStrings.zh-Hans.xlf Same as above for Simplified Chinese resources.
src/Aspire.Cli/Resources/xlf/RunCommandStrings.tr.xlf Same as above for Turkish resources.
src/Aspire.Cli/Resources/xlf/RunCommandStrings.ru.xlf Same as above for Russian resources.
src/Aspire.Cli/Resources/xlf/RunCommandStrings.pt-BR.xlf Same as above for Brazilian Portuguese resources.
src/Aspire.Cli/Resources/xlf/RunCommandStrings.pl.xlf Same as above for Polish resources.
src/Aspire.Cli/Resources/xlf/RunCommandStrings.ko.xlf Same as above for Korean resources.
src/Aspire.Cli/Resources/xlf/RunCommandStrings.ja.xlf Same as above for Japanese resources.
src/Aspire.Cli/Resources/xlf/RunCommandStrings.it.xlf Same as above for Italian resources.
src/Aspire.Cli/Resources/xlf/RunCommandStrings.fr.xlf Same as above for French resources.
src/Aspire.Cli/Resources/xlf/RunCommandStrings.es.xlf Same as above for Spanish resources.
src/Aspire.Cli/Resources/xlf/RunCommandStrings.de.xlf Same as above for German resources.
src/Aspire.Cli/Resources/xlf/RunCommandStrings.cs.xlf Same as above for Czech resources.
src/Aspire.Cli/Resources/RunCommandStrings.resx Added base resources for the isolated-mode argument description and “Copying user secrets for isolated mode...” line.
src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs Exposed strongly-typed properties for the new resource strings used by the CLI.
src/Aspire.Cli/Projects/DotNetAppHostProject.cs Implemented isolated-mode behavior: sets DcpPublisher__RandomizePorts, queries UserSecretsId, clones secrets via IsolatedUserSecretsHelper, wires DOTNET_USER_SECRETS_ID, and cleans up at the end of the run.
src/Aspire.Cli/Projects/AppHostProjectContext.cs Extended the run context with an Isolated flag indicating whether to enable isolated-mode behavior.
src/Aspire.Cli/Commands/RunCommand.cs Added the --isolated option, plumbed it into AppHostProjectContext, and ensured it’s forwarded when running detached.
src/Aspire.Cli/Aspire.Cli.csproj Linked the shared user-secrets helpers into the CLI assembly and declared internals visibility for tests.
Files not reviewed (1)
  • src/Aspire.Cli/Resources/RunCommandStrings.Designer.cs: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@davidfowl
Copy link
Member

davidfowl commented Jan 26, 2026

🧪 End-to-End Test Report: --isolated Flag

CLI Version Tested: 13.2.0-pr.14123.ga0d050a0
Test Date: 2026-01-26


Summary

Overall Assessment: ⚠️ Partially Working - The core isolated mode features work, but there's a critical bug that prevents the primary use case (running multiple instances simultaneously).


What Works ✅

Feature Status
--isolated flag in help ✅ Works
User secrets isolation ("Copying user secrets for isolated mode...") ✅ Works
Port randomization (DcpPublisher__RandomizePorts=true) ✅ Works
Single instance with --isolated ✅ Works

What Doesn't Work ❌

CRITICAL: Running Multiple Instances Simultaneously DOES NOT WORK

This is the primary use case for the --isolated flag, and it fails.

Expected Behavior:
Running aspire run --isolated twice in the same directory should result in both instances running simultaneously on different ports.

Actual Behavior:
When running aspire run --isolated a second time, the CLI:

  1. Detects the existing running instance
  2. Stops the previous instance
  3. Then starts the new instance

Evidence from logs:

[21:35:43] [dbug] DotNetAppHostProject: Connecting to auxiliary backchannel...
ℹ Stopping previous instance (AppHost PID: 113376, CLI PID: 30940)
[21:35:43] [dbug] DotNetAppHostProject: Requesting AppHost to stop
✔ Running instance stopped successfully.

Root Cause:
The "running instance detection" feature is still enabled even when --isolated is specified. The --isolated flag needs to bypass this check to allow multiple simultaneous instances.


Recommendation

When --isolated is true, skip the "is another instance running?" check so that multiple instances can coexist.


@davidfowl
Copy link
Member

Update after reading the Scaling AI Agents with Aspire Isolation blog post:

I may have misunderstood the use case. The blog describes running different worktrees (separate git checkouts) with isolated ports, not running the same AppHost multiple times from the same directory.

However, re-reading the PR description which fixes:

And the flag description: "Run in isolated mode with randomized ports and isolated user secrets, allowing multiple instances to run simultaneously"

It does seem like the intent is to allow multiple instances. Could you clarify the expected behavior?

  1. Same directory scenario: Should aspire run --isolated allow running two instances of the same AppHost from the same terminal/directory?
  2. Different worktrees scenario: Is --isolated only meant to prevent conflicts when running AppHosts from different worktrees/directories?

If it's scenario #2, the current behavior makes sense (stop existing instance in same directory, but isolated ports prevent conflicts with other directories). The test I ran was scenario #1.

@davidfowl
Copy link
Member

✅ Updated Test Results - Worktree Scenario WORKS!

After re-testing with the correct scenario (different directories simulating worktrees), the feature works as intended:

aspire ps
PATH                                                                                       PID     CLI_PID  DASHBOARD
D:\dev\git\dogfood\pr14123-isolated-test\IsolatedTest.AppHost\IsolatedTest.AppHost.csproj  6708    92056    https://localhost:17085/...
D:\dev\git\dogfood\pr14123-worktree2\IsolatedTest.AppHost\IsolatedTest.AppHost.csproj      87152   71484    https://localhost:17085/...

Both instances from different directories are running simultaneously!

Clarification on Behavior

  • Same directory: Running aspire run --isolated twice in the same directory stops the previous instance (expected - avoids build conflicts)
  • Different directories/worktrees: Both instances run simultaneously (this is the intended use case per the blog post)

Minor Issue: Dashboard Port Not Randomized

Both instances show https://localhost:17085 for the dashboard. The DcpPublisher__RandomizePorts setting randomizes the resource ports (APIs, services), but the dashboard port appears to come from launchSettings.json. This might cause conflicts if both dashboards try to bind to the same port.

Could this be addressed by also randomizing the dashboard/OTLP ports in isolated mode?

Summary

The feature works correctly for the worktree use case! My earlier report was based on testing the wrong scenario (same directory).

@JamesNK
Copy link
Member Author

JamesNK commented Jan 26, 2026

Overall Assessment: ⚠️ Partially Working - The core isolated mode features work, but there's a critical bug that prevents the primary use case (running multiple instances simultaneously).

image

Update after reading the Scaling AI Agents with Aspire Isolation blog post:

If it's scenario #2, the current behavior makes sense (stop existing instance in same directory, but isolated ports prevent conflicts with other directories). The test I ran was scenario #1.

CleverGirl

@JamesNK JamesNK force-pushed the jamesnk/isolated branch 2 times, most recently from 11f1c51 to 30993b4 Compare January 27, 2026 07:09
@davidfowl
Copy link
Member

Code Review Findings

🔴 High: Missing exception handling for File.Copy operation

File: src/Shared/UserSecrets/IsolatedUserSecretsHelper.cs:42

The File.Copy operation has no exception handling. If it fails (permissions, disk space, file locks), an unhandled exception will crash app startup. The exception propagates before isolatedUserSecretsId is returned, so cleanup won't work for partially created directories.

Suggested fix: Wrap file operations in try-catch, clean up partial state on failure, and return null to allow graceful fallback.


🟡 Medium: Race condition in directory cleanup

File: src/Shared/UserSecrets/IsolatedUserSecretsHelper.cs:70-75

Non-atomic check-then-delete pattern:

var remainingFiles = Directory.GetFiles(secretsDir);
if (remainingFiles.Length == 0)
{
    Directory.Delete(secretsDir);
}

Between checking and deleting, another process could modify the directory contents.


🟡 Medium: Null check for Path.GetDirectoryName comes too late

File: src/Shared/UserSecrets/IsolatedUserSecretsHelper.cs:61-71

Path.GetDirectoryName(secretsPath) can return null, but the null check at line 69 only guards Directory.Delete. The Directory.GetFiles(secretsDir) call at line 71 will throw if secretsDir is null.

Suggested fix: Check secretsDir is not null before calling Directory.GetFiles.

@davidfowl
Copy link
Member

Suggestion: Support user secrets isolation for single-file apphosts

Currently, the PR skips user secrets isolation for single-file apphosts because GetUserSecretsIdAsync uses MSBuild to extract the UserSecretsId property. However, the UserSecretsIdAttribute is embedded in the compiled assembly and can be read via reflection.

Approach

For single-file apphosts, read the UserSecretsIdAttribute directly from the assembly:

// For single-file apphosts, read UserSecretsId from the assembly attribute
private static string? GetUserSecretsIdFromAssembly(FileInfo appHostFile)
{
    try
    {
        // Use MetadataLoadContext to avoid loading the assembly into the current domain
        var resolver = new PathAssemblyResolver(new[] { appHostFile.FullName, typeof(object).Assembly.Location });
        using var mlc = new MetadataLoadContext(resolver);
        var assembly = mlc.LoadFromAssemblyPath(appHostFile.FullName);
        
        var attr = assembly.GetCustomAttributesData()
            .FirstOrDefault(a => a.AttributeType.FullName == 
                "Microsoft.Extensions.Configuration.UserSecrets.UserSecretsIdAttribute");
        
        return attr?.ConstructorArguments.FirstOrDefault().Value as string;
    }
    catch
    {
        return null;
    }
}

Verification

Tested locally - the UserSecretsIdAttribute is successfully embedded in compiled apphosts:

PS> $assembly = [System.Reflection.Assembly]::LoadFrom("8597.AppHost.dll")
PS> ($assembly.GetCustomAttributes($true) | Where-Object { $_.GetType().Name -eq "UserSecretsIdAttribute" }).UserSecretsId
c945f63b-0095-40f5-8acb-69796193019e

This would allow --isolated mode to fully work with single-file apphosts.

@davidfowl
Copy link
Member

davidfowl commented Jan 27, 2026

Updated: Use dotnet user-secrets --id for single-file/file-based apphosts

The simpler approach is to use dotnet user-secrets list --id <id> which works without needing a project file. For file-based apphosts, dotnet user-secrets -f <file> also works:

PS> dotnet user-secrets list -f apphost.cs
Parameters:cache-password = <redacted>
AppHost:OtlpApiKey = <redacted>

However, for isolated mode you still need to know the UserSecretsId to copy the secrets. The challenge is getting that ID from a file-based apphost without MSBuild.

For file-based apps, you could:

  1. Parse the generated .csproj in the .aspire folder (if it exists)
  2. Or use dotnet user-secrets list -f apphost.cs --json and infer the path from the output

Actually, looking at the .aspire folder approach - file-based apphosts generate a .csproj there that should contain the UserSecretsId.

@davidfowl
Copy link
Member

Complete solution for file-based apphost user secrets isolation

Testing with dotnet user-secrets list -f apphost.cs -v reveals how file-based apphosts work:

Project file path D:\dev\git\dogfood\HelloNoSingleFile\apphost.cs.
Secrets file path C:\Users\davifowl\AppData\Roaming\Microsoft\UserSecrets\apphost-7e7db3cd348faf0e130ca7f28443fda9...\secrets.json

The UserSecretsId for file-based apps is deterministically generated from the file path: apphost-<sha256-hash-of-path>.

Suggested approach for file-based apphosts:

private static string GetFileBasedAppHostUserSecretsId(FileInfo appHostFile)
{
    // Match the logic used by dotnet user-secrets for file-based apps
    var fullPath = appHostFile.FullName;
    using var sha256 = SHA256.Create();
    var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(fullPath));
    var hashString = Convert.ToHexString(hash).ToLowerInvariant();
    return $"apphost-{hashString}";
}

Then in ConfigureIsolatedModeAsync, for file-based apphosts:

if (isSingleFileAppHost)
{
    var userSecretsId = GetFileBasedAppHostUserSecretsId(appHostFile);
    // ... proceed with CreateIsolatedUserSecrets(userSecretsId)
}

This would enable full --isolated mode support for file-based apphosts.

@davidfowl
Copy link
Member

OR we shell out to user secrets id in all cases?

@JamesNK
Copy link
Member Author

JamesNK commented Jan 27, 2026

OR we shell out to user secrets id in all cases?

The existing GetProjectItemsAndPropertiesAsync works with single file apps. No change needed other than actually running the same code for single file apps.

@davidfowl
Copy link
Member

PR Testing Report - --isolated Flag

CLI Version Verification

  • Expected Commit: a362413807b77217d30fa42c15680969c6701919
  • Installed Version: 13.2.0-pr.14123.ga3624138
  • Status: ✅ Verified

Test Scenarios

1. --isolated Flag Exists ✅

Options:
  --isolated             Run in isolated mode with randomized ports and isolated user secrets, allowing multiple
                         instances to run simultaneously.

2. Git Worktree Scenario - Multiple Standard AppHosts ✅

Same project name (SharedApp.AppHost.csproj) from two different directories ran simultaneously:

Instance Directory Dashboard Port
Worktree 1 worktree1/SharedApp.AppHost 17204
Worktree 2 worktree2/SharedApp.AppHost 17170
PATH                      PID    CLI_PID  DASHBOARD
SharedApp.AppHost.csproj  55388  83700    https://localhost:17170/...
SharedApp.AppHost.csproj  35024  62944    https://localhost:17204/...

3. File-Based AppHosts (Python FastAPI) ✅

Multiple file-based apphosts ran simultaneously:

PATH                        PID    CLI_PID  DASHBOARD
python-fastapi2/apphost.cs  82752  75492    -
python-fastapi/apphost.cs   68152  53340    -

4. Isolated User Secrets - Standard Projects ✅

Set different secrets in each instance:

  • Instance 1: "TestSecret": "SECRET_FROM_INSTANCE_1"
  • Instance 2: "TestSecret": "SECRET_FROM_INSTANCE_2"

Verified isolated copies were created at runtime:

  • Instance 1 isolated (65426f11-...): Contains SECRET_FROM_INSTANCE_1
  • Instance 2 isolated (105b09ac-...): Contains SECRET_FROM_INSTANCE_2

Each instance gets its own copy of user secrets, preventing cross-contamination.

5. Isolated User Secrets - File-Based AppHosts ✅

File-based apphosts create isolated secrets with apphost-<hash> naming:

apphost-77b4748515338f4cc965705503d9c099c421c4bd65b7d5ad5011434a25c3ccf7
  OtlpApiKey: e20919448e65e129719e66eab85bd44d
  McpApiKey: a328155a626432fee1bf304b8d746753

Summary

Feature Status
--isolated flag added
Randomized ports
Multiple instances simultaneously
Git worktree support (same project, different dirs)
File-based apphost support
Isolated user secrets (standard projects)
Isolated user secrets (file-based)

Overall Result

✅ PR VERIFIED - All scenarios passed successfully.

@davidfowl
Copy link
Member

Additional Test: TypeScript AppHost ✅

Tested TypeScript apphosts created with aspire init --language typescript:

Instance File Dashboard Port
ts-apphost1 apphost.ts 12351
ts-apphost2 apphost.ts 38159
PATH                      PID    CLI_PID  DASHBOARD
ts-apphost2/apphost.ts    53196  80908    https://localhost:38159/...
ts-apphost1/apphost.ts    91456  49120    https://localhost:12351/...

Both TypeScript apphosts ran simultaneously with --isolated, confirming the feature works across all apphost types:

  • ✅ Standard .csproj apphosts
  • ✅ File-based C# apphosts (apphost.cs)
  • ✅ File-based Python apphosts (FastAPI)
  • ✅ File-based TypeScript apphosts (apphost.ts)

@davidfowl
Copy link
Member

Additional Test: TypeScript AppHost Folder Copy ✅

Tested copying an identical TypeScript apphost folder and running both with --isolated:

Setup

  1. Created TypeScript apphost with aspire init --language typescript
  2. Copied entire folder to create identical duplicate
  3. Ran both with --isolated

Results

PATH                      PID    CLI_PID  DASHBOARD
copy/apphost.ts           81140  62432    https://localhost:53140/...
original/apphost.ts       15556  75768    https://localhost:53140/...

Isolated User Secrets Verified

Each instance received its own unique secrets:

Instance OtlpApiKey McpApiKey
Original 7c8a72d88a7e76f1... e71945a93bd6ec92...
Copy 3a1e0098deb5099c... 27e6607a6a7f56f0...

User secrets are correctly isolated per TypeScript apphost instance, even when running identical folder copies.

@davidfowl davidfowl merged commit b84aedf into main Jan 28, 2026
330 checks passed
@davidfowl davidfowl deleted the jamesnk/isolated branch January 28, 2026 04:37
@dotnet-policy-service dotnet-policy-service bot added this to the 13.2 milestone Jan 28, 2026
@davidfowl
Copy link
Member

@JamesNK I think we missed the resource server URL. Not I'm wondering if we got lucky? Are we randomizing the DashboardServiceHost ?

@JamesNK
Copy link
Member Author

JamesNK commented Jan 29, 2026

Yes, it's randomized.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow the Aspire MCP to work well with git worktrees

3 participants