From 971b73fc56874959d17959a7142d775909d638dd Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 8 Apr 2026 17:35:29 -0400 Subject: [PATCH 1/5] Fix aspire start failing in VS Code integrated terminal When 'aspire start' runs from the VS Code Aspire terminal, the detached child process inherits ASPIRE_EXTENSION_* environment variables, causing it to detect extension mode and delegate back to the extension via StartDebugSessionAsync instead of launching the AppHost. This results in an immediate exit with code 0. Fix: - Strip extension-related env vars from the detached child process in DetachedProcessLauncher (Unix: remove from startInfo.Environment, Windows: build filtered environment block for CreateProcessW) - Add --non-interactive guard in RunCommand to skip extension delegation when running as a child of 'aspire start' - Set isExtensionHost=false in StartCommand so Dashboard URL always shows in 'aspire start' output - Fix column width calculation in RenderAppHostSummary to only include labels that are actually displayed - Add missing KnownConfigNames constants for extension env vars Fixes #15786 --- src/Aspire.Cli/Commands/AppHostLauncher.cs | 28 ++++- src/Aspire.Cli/Commands/RunCommand.cs | 16 ++- src/Aspire.Cli/Commands/StartCommand.cs | 4 +- .../Processes/DetachedProcessLauncher.Unix.cs | 12 +- .../DetachedProcessLauncher.Windows.cs | 110 ++++++++++++++---- .../Processes/DetachedProcessLauncher.cs | 7 +- src/Shared/KnownConfigNames.cs | 6 + .../Commands/RunCommandTests.cs | 77 ++++++++++++ 8 files changed, 227 insertions(+), 33 deletions(-) diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index 47b6b1d042f..df821de5ff8 100644 --- a/src/Aspire.Cli/Commands/AppHostLauncher.cs +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -11,6 +11,7 @@ using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Utils; +using Aspire.Hosting; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Commands; @@ -212,6 +213,30 @@ private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, Can return (dotnetPath, childArgs); } + /// + /// Extension-related environment variable names that must be removed from + /// the detached child process. When the parent CLI runs inside the VS Code + /// Aspire terminal, these vars are set — but the child must not see them + /// because it would incorrectly detect extension mode and delegate back to + /// the extension instead of launching the AppHost. + /// + private static readonly HashSet s_extensionEnvironmentVariables = new(StringComparer.OrdinalIgnoreCase) + { + KnownConfigNames.ExtensionEndpoint, + KnownConfigNames.ExtensionToken, + KnownConfigNames.ExtensionCert, + KnownConfigNames.ExtensionPromptEnabled, + KnownConfigNames.ExtensionDebugSessionId, + KnownConfigNames.ExtensionDebugRunMode, + KnownConfigNames.ExtensionCapabilities, + KnownConfigNames.DebugSessionInfo, + KnownConfigNames.DebugSessionRunMode, + KnownConfigNames.DebugSessionPort, + KnownConfigNames.DebugSessionToken, + KnownConfigNames.DebugSessionServerCertificate, + KnownConfigNames.DcpInstanceIdPrefix, + }; + private record LaunchResult(Process? ChildProcess, IAppHostAuxiliaryBackchannel? Backchannel, DashboardUrlsState? DashboardUrls, bool ChildExitedEarly, int ChildExitCode); private async Task LaunchAndWaitForBackchannelAsync( @@ -227,7 +252,8 @@ private async Task LaunchAndWaitForBackchannelAsync( childProcess = DetachedProcessLauncher.Start( executablePath, childArgs, - executionContext.WorkingDirectory.FullName); + executionContext.WorkingDirectory.FullName, + s_extensionEnvironmentVariables); } catch (Exception ex) { diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index c2a1a1acf9a..a618452bbce 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -165,8 +165,12 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell } // A user may run `aspire run` in an Aspire terminal in VS Code. In this case, intercept and prompt - // VS Code to start a debug session using the current directory - if (ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _) + // VS Code to start a debug session using the current directory. + // Skip this when running in non-interactive mode (e.g. as a child of `aspire start`) + // to avoid delegating back to the extension instead of launching the AppHost directly. + var nonInteractive = parseResult.GetValue(RootCommand.NonInteractiveOption); + if (!nonInteractive + && ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _) && string.IsNullOrEmpty(_configuration[KnownConfigNames.ExtensionDebugSessionId])) { extensionInteractionService.DisplayConsolePlainText(RunCommandStrings.StartingDebugSessionInExtension); @@ -463,8 +467,12 @@ internal static int RenderAppHostSummary( var logsLabel = RunCommandStrings.Logs; var pidLabel = RunCommandStrings.ProcessId; - // Calculate column width based on all possible labels - var labels = new List { appHostLabel, dashboardLabel, logsLabel }; + // Calculate column width based on labels that will actually be displayed + var labels = new List { appHostLabel, logsLabel }; + if (!isExtensionHost) + { + labels.Add(dashboardLabel); + } if (pid.HasValue) { labels.Add(pidLabel); diff --git a/src/Aspire.Cli/Commands/StartCommand.cs b/src/Aspire.Cli/Commands/StartCommand.cs index 2d1a301dec9..f147111726d 100644 --- a/src/Aspire.Cli/Commands/StartCommand.cs +++ b/src/Aspire.Cli/Commands/StartCommand.cs @@ -48,7 +48,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption); var noBuild = parseResult.GetValue(s_noBuildOption); - var isExtensionHost = ExtensionHelper.IsExtensionHost(_interactionService, out _, out _); + // `aspire start` is always user-initiated, so we never suppress dashboard URLs + // in the summary — even when running inside the VS Code extension terminal. + var isExtensionHost = false; var waitForDebugger = parseResult.GetValue(RootCommand.WaitForDebuggerOption); var globalArgs = RootCommand.GetChildProcessArgs(parseResult); var additionalArgs = parseResult.UnmatchedTokens.ToList(); diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs index ee7620a2fd2..6586686769c 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs @@ -15,7 +15,7 @@ internal static partial class DetachedProcessLauncher /// pipe has no reader and writes produce EPIPE (harmless). The key difference from /// Windows is that on Unix, only fds 0/1/2 survive exec — no extra handle leakage. /// - private static Process StartUnix(string fileName, IReadOnlyList arguments, string workingDirectory) + private static Process StartUnix(string fileName, IReadOnlyList arguments, string workingDirectory, IReadOnlySet? environmentVariablesToRemove) { var startInfo = new ProcessStartInfo { @@ -33,6 +33,16 @@ private static Process StartUnix(string fileName, IReadOnlyList argument startInfo.ArgumentList.Add(arg); } + // Remove specified environment variables from the child process. + // Accessing startInfo.Environment auto-populates from the current process. + if (environmentVariablesToRemove is { Count: > 0 }) + { + foreach (var varName in environmentVariablesToRemove) + { + startInfo.Environment.Remove(varName); + } + } + var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start detached process"); diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs index da509a0be25..e9daa66f050 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs @@ -17,7 +17,7 @@ internal static partial class DetachedProcessLauncher /// PROC_THREAD_ATTRIBUTE_HANDLE_LIST to prevent handle inheritance to grandchildren. /// [SupportedOSPlatform("windows")] - private static Process StartWindows(string fileName, IReadOnlyList arguments, string workingDirectory) + private static Process StartWindows(string fileName, IReadOnlyList arguments, string workingDirectory, IReadOnlySet? environmentVariablesToRemove) { // Open NUL device for stdout/stderr — child writes go nowhere using var nulHandle = CreateFileW( @@ -88,34 +88,53 @@ private static Process StartWindows(string fileName, IReadOnlyList argum var flags = CreateUnicodeEnvironment | ExtendedStartupInfoPresent | CreateNoWindow; - if (!CreateProcessW( - null, - commandLine, - nint.Zero, - nint.Zero, - bInheritHandles: true, // TRUE but HANDLE_LIST restricts what's actually inherited - flags, - nint.Zero, - workingDirectory, - ref si, - out var pi)) - { - throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create detached process"); - } - - Process detachedProcess; + // Build a filtered environment block if variables need to be removed. + // CreateProcessW with lpEnvironment=nint.Zero inherits the parent's + // environment, so we only build a custom block when filtering is needed. + var envBlockHandle = nint.Zero; try { - detachedProcess = Process.GetProcessById(pi.dwProcessId); + if (environmentVariablesToRemove is { Count: > 0 }) + { + envBlockHandle = BuildFilteredEnvironmentBlock(environmentVariablesToRemove); + } + + if (!CreateProcessW( + null, + commandLine, + nint.Zero, + nint.Zero, + bInheritHandles: true, // TRUE but HANDLE_LIST restricts what's actually inherited + flags, + envBlockHandle, + workingDirectory, + ref si, + out var pi)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create detached process"); + } + + Process detachedProcess; + try + { + detachedProcess = Process.GetProcessById(pi.dwProcessId); + } + finally + { + // Close the process and thread handles returned by CreateProcess. + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + } + + return detachedProcess; } finally { - // Close the process and thread handles returned by CreateProcess. - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); + if (envBlockHandle != nint.Zero) + { + Marshal.FreeHGlobal(envBlockHandle); + } } - - return detachedProcess; } finally { @@ -217,6 +236,51 @@ private static void AppendArgument(StringBuilder sb, string argument) sb.Append('"'); } + /// + /// Builds a Unicode environment block for CreateProcessW with specified variables removed. + /// The block is sorted by variable name (case-insensitive, as required by Windows) + /// and double-null-terminated. The caller must free the returned pointer with Marshal.FreeHGlobal. + /// + [SupportedOSPlatform("windows")] + private static nint BuildFilteredEnvironmentBlock(IReadOnlySet variablesToRemove) + { + // Collect current environment variables, excluding the ones to remove. + var envVars = new SortedDictionary(StringComparer.OrdinalIgnoreCase); + foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + var key = (string)entry.Key; + if (!variablesToRemove.Contains(key)) + { + envVars[key] = (string?)entry.Value ?? string.Empty; + } + } + + // Build the double-null-terminated Unicode environment block: + // KEY1=VALUE1\0KEY2=VALUE2\0...\0\0 + var blockBuilder = new StringBuilder(); + foreach (var kvp in envVars) + { + blockBuilder.Append(kvp.Key); + blockBuilder.Append('='); + blockBuilder.Append(kvp.Value); + blockBuilder.Append('\0'); + } + blockBuilder.Append('\0'); // Final terminator + + var blockString = blockBuilder.ToString(); + var byteCount = Encoding.Unicode.GetByteCount(blockString); + var ptr = Marshal.AllocHGlobal(byteCount); + unsafe + { + fixed (char* pStr = blockString) + { + Encoding.Unicode.GetBytes(pStr, blockString.Length, (byte*)ptr, byteCount); + } + } + + return ptr; + } + // --- Constants --- private const uint GenericWrite = 0x40000000; private const uint FileShareWrite = 0x00000002; diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs index 4b65f650998..b45560df26f 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs @@ -67,14 +67,15 @@ internal static partial class DetachedProcessLauncher /// The executable path (e.g. dotnet or the native CLI). /// The command-line arguments for the child process. /// The working directory for the child process. + /// Optional set of environment variable names to remove from the child process. /// A object representing the launched child. - public static Process Start(string fileName, IReadOnlyList arguments, string workingDirectory) + public static Process Start(string fileName, IReadOnlyList arguments, string workingDirectory, IReadOnlySet? environmentVariablesToRemove = null) { if (OperatingSystem.IsWindows()) { - return StartWindows(fileName, arguments, workingDirectory); + return StartWindows(fileName, arguments, workingDirectory, environmentVariablesToRemove); } - return StartUnix(fileName, arguments, workingDirectory); + return StartUnix(fileName, arguments, workingDirectory, environmentVariablesToRemove); } } diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index 83ce73d3443..b277c7077ca 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -47,6 +47,8 @@ internal static class KnownConfigNames public const string ExtensionToken = "ASPIRE_EXTENSION_TOKEN"; public const string ExtensionCert = "ASPIRE_EXTENSION_CERT"; public const string ExtensionDebugSessionId = "ASPIRE_EXTENSION_DEBUG_SESSION_ID"; + public const string ExtensionDebugRunMode = "ASPIRE_EXTENSION_DEBUG_RUN_MODE"; + public const string ExtensionCapabilities = "ASPIRE_EXTENSION_CAPABILITIES"; public const string DeveloperCertificateDefaultTrust = "ASPIRE_DEVELOPER_CERTIFICATE_DEFAULT_TRUST"; public const string DeveloperCertificateDefaultHttpsTermination = "ASPIRE_DEVELOPER_CERTIFICATE_DEFAULT_HTTPS_TERMINATION"; @@ -54,6 +56,10 @@ internal static class KnownConfigNames public const string DebugSessionInfo = "DEBUG_SESSION_INFO"; public const string DebugSessionRunMode = "DEBUG_SESSION_RUN_MODE"; + public const string DebugSessionPort = "DEBUG_SESSION_PORT"; + public const string DebugSessionToken = "DEBUG_SESSION_TOKEN"; + public const string DebugSessionServerCertificate = "DEBUG_SESSION_SERVER_CERTIFICATE"; + public const string DcpInstanceIdPrefix = "DCP_INSTANCE_ID_PREFIX"; public static class Legacy { diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 27efdc26b39..699639f74f0 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -1559,4 +1559,81 @@ async IAsyncEnumerable YieldEntries([EnumeratorCancellation } } + [Fact] + public async Task RunCommand_NonInteractive_SkipsExtensionDelegation() + { + // When `aspire start` spawns `aspire run --non-interactive`, the child process + // may inherit ASPIRE_EXTENSION_* env vars from the parent terminal. Without the + // --non-interactive guard, the child would delegate to the extension via + // StartDebugSessionAsync and exit immediately instead of launching the AppHost. + var startDebugSessionCalled = false; + + var extensionBackchannel = new TestExtensionBackchannel(); + extensionBackchannel.GetCapabilitiesAsyncCallback = ct => Task.FromResult(Array.Empty()); + + var appHostBackchannel = new TestAppHostBackchannel(); + appHostBackchannel.GetDashboardUrlsAsyncCallback = (ct) => Task.FromResult(new DashboardUrlsState + { + DashboardHealthy = true, + BaseUrlWithLoginToken = "http://localhost/dashboard", + CodespacesUrlWithLoginToken = null + }); + appHostBackchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync; + + var backchannelFactory = (IServiceProvider sp) => appHostBackchannel; + + var extensionInteractionServiceFactory = (IServiceProvider sp) => + { + var service = new TestExtensionInteractionService(sp); + service.StartDebugSessionCallback = (_, _, _) => + { + startDebugSessionCalled = true; + }; + return service; + }; + + var runnerFactory = (IServiceProvider sp) => + { + var runner = new TestDotNetCliRunner(); + runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0; + runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); + runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => + { + var backchannel = sp.GetRequiredService(); + backchannelCompletionSource!.SetResult(backchannel); + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + return 0; + }; + return runner; + }; + + var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator(); + + using var workspace = TemporaryWorkspace.Create(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = projectLocatorFactory; + options.AppHostBackchannelFactory = backchannelFactory; + options.DotNetCliRunnerFactory = runnerFactory; + options.ExtensionBackchannelFactory = _ => extensionBackchannel; + options.InteractionServiceFactory = extensionInteractionServiceFactory; + // Deliberately NOT setting ASPIRE_EXTENSION_DEBUG_SESSION_ID — + // without --non-interactive, this would trigger the early return. + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + // Parse with --non-interactive to simulate the child of `aspire start` + var result = command.Parse("run --non-interactive"); + + using var cts = new CancellationTokenSource(); + var pendingRun = result.InvokeAsync(cancellationToken: cts.Token); + cts.Cancel(); + + var exitCode = await pendingRun.DefaultTimeout(); + + Assert.Equal(ExitCodeConstants.Success, exitCode); + Assert.False(startDebugSessionCalled, "StartDebugSessionAsync should not be called in non-interactive mode."); + } + } From a50af6b1018c254947a514b5d9a8ce2e89587ad7 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 8 Apr 2026 17:47:06 -0400 Subject: [PATCH 2/5] Fix detached child env filtering Preserve DEBUG_SESSION_* values when the detached child CLI is launched from the VS Code Aspire terminal so the AppHost keeps IDE execution and dashboard debug integration. Also address follow-up review issues in StartCommand and the Windows filtered environment block builder. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Cli/Commands/AppHostLauncher.cs | 23 ++++++++----------- src/Aspire.Cli/Commands/StartCommand.cs | 2 -- .../DetachedProcessLauncher.Windows.cs | 6 +++++ .../Commands/RunCommandTests.cs | 16 +++++++++++++ 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index df821de5ff8..5de873edd68 100644 --- a/src/Aspire.Cli/Commands/AppHostLauncher.cs +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -214,13 +214,11 @@ private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, Can } /// - /// Extension-related environment variable names that must be removed from - /// the detached child process. When the parent CLI runs inside the VS Code - /// Aspire terminal, these vars are set — but the child must not see them - /// because it would incorrectly detect extension mode and delegate back to - /// the extension instead of launching the AppHost. + /// Environment variable names that make a detached child CLI run in extension-host mode. + /// Keep the DEBUG_SESSION_* and DCP session variables intact because the launched AppHost + /// still relies on them for IDE execution and dashboard integration. /// - private static readonly HashSet s_extensionEnvironmentVariables = new(StringComparer.OrdinalIgnoreCase) + private static readonly HashSet s_extensionHostEnvironmentVariables = new(StringComparer.OrdinalIgnoreCase) { KnownConfigNames.ExtensionEndpoint, KnownConfigNames.ExtensionToken, @@ -229,14 +227,13 @@ private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, Can KnownConfigNames.ExtensionDebugSessionId, KnownConfigNames.ExtensionDebugRunMode, KnownConfigNames.ExtensionCapabilities, - KnownConfigNames.DebugSessionInfo, - KnownConfigNames.DebugSessionRunMode, - KnownConfigNames.DebugSessionPort, - KnownConfigNames.DebugSessionToken, - KnownConfigNames.DebugSessionServerCertificate, - KnownConfigNames.DcpInstanceIdPrefix, }; + /// + /// Gets the environment variables removed from detached child CLI processes. + /// + internal static IReadOnlySet DetachedChildEnvironmentVariablesToRemove => s_extensionHostEnvironmentVariables; + private record LaunchResult(Process? ChildProcess, IAppHostAuxiliaryBackchannel? Backchannel, DashboardUrlsState? DashboardUrls, bool ChildExitedEarly, int ChildExitCode); private async Task LaunchAndWaitForBackchannelAsync( @@ -253,7 +250,7 @@ private async Task LaunchAndWaitForBackchannelAsync( executablePath, childArgs, executionContext.WorkingDirectory.FullName, - s_extensionEnvironmentVariables); + s_extensionHostEnvironmentVariables); } catch (Exception ex) { diff --git a/src/Aspire.Cli/Commands/StartCommand.cs b/src/Aspire.Cli/Commands/StartCommand.cs index f147111726d..b3315adeec3 100644 --- a/src/Aspire.Cli/Commands/StartCommand.cs +++ b/src/Aspire.Cli/Commands/StartCommand.cs @@ -15,7 +15,6 @@ internal sealed class StartCommand : BaseCommand internal override HelpGroup HelpGroup => HelpGroup.AppCommands; private readonly AppHostLauncher _appHostLauncher; - private readonly IInteractionService _interactionService; private static readonly Option s_noBuildOption = new("--no-build") { @@ -32,7 +31,6 @@ public StartCommand( : base("start", StartCommandStrings.Description, features, updateNotifier, executionContext, interactionService, telemetry) { - _interactionService = interactionService; _appHostLauncher = appHostLauncher; Options.Add(s_noBuildOption); diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs index e9daa66f050..07c3ee1222d 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs @@ -265,6 +265,12 @@ private static nint BuildFilteredEnvironmentBlock(IReadOnlySet variables blockBuilder.Append(kvp.Value); blockBuilder.Append('\0'); } + + if (envVars.Count == 0) + { + blockBuilder.Append('\0'); + } + blockBuilder.Append('\0'); // Final terminator var blockString = blockBuilder.ToString(); diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 699639f74f0..906dff8d0ca 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -13,6 +13,7 @@ using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; using Aspire.Cli.Utils; +using Aspire.Hosting; using Aspire.Shared.UserSecrets; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; @@ -1636,4 +1637,19 @@ public async Task RunCommand_NonInteractive_SkipsExtensionDelegation() Assert.False(startDebugSessionCalled, "StartDebugSessionAsync should not be called in non-interactive mode."); } + [Fact] + public void DetachedChildEnvironmentFilter_PreservesDebugSessionVariables() + { + var filteredVariables = AppHostLauncher.DetachedChildEnvironmentVariablesToRemove; + + Assert.Contains(KnownConfigNames.ExtensionEndpoint, filteredVariables); + Assert.Contains(KnownConfigNames.ExtensionDebugSessionId, filteredVariables); + Assert.DoesNotContain(KnownConfigNames.DebugSessionInfo, filteredVariables); + Assert.DoesNotContain(KnownConfigNames.DebugSessionRunMode, filteredVariables); + Assert.DoesNotContain(KnownConfigNames.DebugSessionPort, filteredVariables); + Assert.DoesNotContain(KnownConfigNames.DebugSessionToken, filteredVariables); + Assert.DoesNotContain(KnownConfigNames.DebugSessionServerCertificate, filteredVariables); + Assert.DoesNotContain(KnownConfigNames.DcpInstanceIdPrefix, filteredVariables); + } + } From 44c982680bc6659384c5e7c9ddde2d543ef36800 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 9 Apr 2026 02:18:05 -0400 Subject: [PATCH 3/5] Address review: prefix-based env var filter, remove unused usings --- src/Aspire.Cli/Commands/AppHostLauncher.cs | 24 +++++++------------ src/Aspire.Cli/Commands/StartCommand.cs | 1 - .../Processes/DetachedProcessLauncher.Unix.cs | 17 +++++++++---- .../DetachedProcessLauncher.Windows.cs | 10 ++++---- .../Processes/DetachedProcessLauncher.cs | 8 +++---- .../Commands/RunCommandTests.cs | 21 ++++++++-------- 6 files changed, 42 insertions(+), 39 deletions(-) diff --git a/src/Aspire.Cli/Commands/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index 5de873edd68..c38a791214d 100644 --- a/src/Aspire.Cli/Commands/AppHostLauncher.cs +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -11,7 +11,6 @@ using Aspire.Cli.Projects; using Aspire.Cli.Resources; using Aspire.Cli.Utils; -using Aspire.Hosting; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Commands; @@ -214,25 +213,20 @@ private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, Can } /// - /// Environment variable names that make a detached child CLI run in extension-host mode. + /// Prefix for environment variables that configure extension-host mode. + /// Any environment variable starting with this prefix is removed from + /// detached child processes to prevent them from entering extension mode. /// Keep the DEBUG_SESSION_* and DCP session variables intact because the launched AppHost /// still relies on them for IDE execution and dashboard integration. /// - private static readonly HashSet s_extensionHostEnvironmentVariables = new(StringComparer.OrdinalIgnoreCase) - { - KnownConfigNames.ExtensionEndpoint, - KnownConfigNames.ExtensionToken, - KnownConfigNames.ExtensionCert, - KnownConfigNames.ExtensionPromptEnabled, - KnownConfigNames.ExtensionDebugSessionId, - KnownConfigNames.ExtensionDebugRunMode, - KnownConfigNames.ExtensionCapabilities, - }; + internal const string ExtensionEnvironmentVariablePrefix = "ASPIRE_EXTENSION_"; /// - /// Gets the environment variables removed from detached child CLI processes. + /// Returns if the specified environment variable name + /// should be removed from detached child CLI processes. /// - internal static IReadOnlySet DetachedChildEnvironmentVariablesToRemove => s_extensionHostEnvironmentVariables; + internal static bool IsExtensionEnvironmentVariable(string name) => + name.StartsWith(ExtensionEnvironmentVariablePrefix, StringComparison.OrdinalIgnoreCase); private record LaunchResult(Process? ChildProcess, IAppHostAuxiliaryBackchannel? Backchannel, DashboardUrlsState? DashboardUrls, bool ChildExitedEarly, int ChildExitCode); @@ -250,7 +244,7 @@ private async Task LaunchAndWaitForBackchannelAsync( executablePath, childArgs, executionContext.WorkingDirectory.FullName, - s_extensionHostEnvironmentVariables); + IsExtensionEnvironmentVariable); } catch (Exception ex) { diff --git a/src/Aspire.Cli/Commands/StartCommand.cs b/src/Aspire.Cli/Commands/StartCommand.cs index b3315adeec3..733fc6b8a2e 100644 --- a/src/Aspire.Cli/Commands/StartCommand.cs +++ b/src/Aspire.Cli/Commands/StartCommand.cs @@ -6,7 +6,6 @@ using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; -using Aspire.Cli.Utils; namespace Aspire.Cli.Commands; diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs index 6586686769c..8fa7129a8e9 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs @@ -15,7 +15,7 @@ internal static partial class DetachedProcessLauncher /// pipe has no reader and writes produce EPIPE (harmless). The key difference from /// Windows is that on Unix, only fds 0/1/2 survive exec — no extra handle leakage. /// - private static Process StartUnix(string fileName, IReadOnlyList arguments, string workingDirectory, IReadOnlySet? environmentVariablesToRemove) + private static Process StartUnix(string fileName, IReadOnlyList arguments, string workingDirectory, Func? shouldRemoveEnvironmentVariable) { var startInfo = new ProcessStartInfo { @@ -35,11 +35,20 @@ private static Process StartUnix(string fileName, IReadOnlyList argument // Remove specified environment variables from the child process. // Accessing startInfo.Environment auto-populates from the current process. - if (environmentVariablesToRemove is { Count: > 0 }) + if (shouldRemoveEnvironmentVariable is not null) { - foreach (var varName in environmentVariablesToRemove) + var keysToRemove = new List(); + foreach (var key in startInfo.Environment.Keys) { - startInfo.Environment.Remove(varName); + if (shouldRemoveEnvironmentVariable(key)) + { + keysToRemove.Add(key); + } + } + + foreach (var key in keysToRemove) + { + startInfo.Environment.Remove(key); } } diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs index 07c3ee1222d..7628a72fc27 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs @@ -17,7 +17,7 @@ internal static partial class DetachedProcessLauncher /// PROC_THREAD_ATTRIBUTE_HANDLE_LIST to prevent handle inheritance to grandchildren. /// [SupportedOSPlatform("windows")] - private static Process StartWindows(string fileName, IReadOnlyList arguments, string workingDirectory, IReadOnlySet? environmentVariablesToRemove) + private static Process StartWindows(string fileName, IReadOnlyList arguments, string workingDirectory, Func? shouldRemoveEnvironmentVariable) { // Open NUL device for stdout/stderr — child writes go nowhere using var nulHandle = CreateFileW( @@ -94,9 +94,9 @@ private static Process StartWindows(string fileName, IReadOnlyList argum var envBlockHandle = nint.Zero; try { - if (environmentVariablesToRemove is { Count: > 0 }) + if (shouldRemoveEnvironmentVariable is not null) { - envBlockHandle = BuildFilteredEnvironmentBlock(environmentVariablesToRemove); + envBlockHandle = BuildFilteredEnvironmentBlock(shouldRemoveEnvironmentVariable); } if (!CreateProcessW( @@ -242,14 +242,14 @@ private static void AppendArgument(StringBuilder sb, string argument) /// and double-null-terminated. The caller must free the returned pointer with Marshal.FreeHGlobal. /// [SupportedOSPlatform("windows")] - private static nint BuildFilteredEnvironmentBlock(IReadOnlySet variablesToRemove) + private static nint BuildFilteredEnvironmentBlock(Func shouldRemove) { // Collect current environment variables, excluding the ones to remove. var envVars = new SortedDictionary(StringComparer.OrdinalIgnoreCase); foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) { var key = (string)entry.Key; - if (!variablesToRemove.Contains(key)) + if (!shouldRemove(key)) { envVars[key] = (string?)entry.Value ?? string.Empty; } diff --git a/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs b/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs index b45560df26f..1110cd39f32 100644 --- a/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs +++ b/src/Aspire.Cli/Processes/DetachedProcessLauncher.cs @@ -67,15 +67,15 @@ internal static partial class DetachedProcessLauncher /// The executable path (e.g. dotnet or the native CLI). /// The command-line arguments for the child process. /// The working directory for the child process. - /// Optional set of environment variable names to remove from the child process. + /// Optional predicate that returns for environment variable names that should be removed from the child process. /// A object representing the launched child. - public static Process Start(string fileName, IReadOnlyList arguments, string workingDirectory, IReadOnlySet? environmentVariablesToRemove = null) + public static Process Start(string fileName, IReadOnlyList arguments, string workingDirectory, Func? shouldRemoveEnvironmentVariable = null) { if (OperatingSystem.IsWindows()) { - return StartWindows(fileName, arguments, workingDirectory, environmentVariablesToRemove); + return StartWindows(fileName, arguments, workingDirectory, shouldRemoveEnvironmentVariable); } - return StartUnix(fileName, arguments, workingDirectory, environmentVariablesToRemove); + return StartUnix(fileName, arguments, workingDirectory, shouldRemoveEnvironmentVariable); } } diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index 906dff8d0ca..fbd59c9fb9e 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -1640,16 +1640,17 @@ public async Task RunCommand_NonInteractive_SkipsExtensionDelegation() [Fact] public void DetachedChildEnvironmentFilter_PreservesDebugSessionVariables() { - var filteredVariables = AppHostLauncher.DetachedChildEnvironmentVariablesToRemove; - - Assert.Contains(KnownConfigNames.ExtensionEndpoint, filteredVariables); - Assert.Contains(KnownConfigNames.ExtensionDebugSessionId, filteredVariables); - Assert.DoesNotContain(KnownConfigNames.DebugSessionInfo, filteredVariables); - Assert.DoesNotContain(KnownConfigNames.DebugSessionRunMode, filteredVariables); - Assert.DoesNotContain(KnownConfigNames.DebugSessionPort, filteredVariables); - Assert.DoesNotContain(KnownConfigNames.DebugSessionToken, filteredVariables); - Assert.DoesNotContain(KnownConfigNames.DebugSessionServerCertificate, filteredVariables); - Assert.DoesNotContain(KnownConfigNames.DcpInstanceIdPrefix, filteredVariables); + // Extension variables use the ASPIRE_EXTENSION_ prefix and should be filtered + Assert.True(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.ExtensionEndpoint)); + Assert.True(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.ExtensionDebugSessionId)); + + // DEBUG_SESSION variables should NOT be filtered + Assert.False(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.DebugSessionInfo)); + Assert.False(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.DebugSessionRunMode)); + Assert.False(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.DebugSessionPort)); + Assert.False(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.DebugSessionToken)); + Assert.False(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.DebugSessionServerCertificate)); + Assert.False(AppHostLauncher.IsExtensionEnvironmentVariable(KnownConfigNames.DcpInstanceIdPrefix)); } } From 5dfc77586b984224038a5adbed5d7b59c7a62edc Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 9 Apr 2026 02:27:04 -0400 Subject: [PATCH 4/5] Restore using Aspire.Cli.Utils needed for ICliUpdateNotifier --- extension/src/utils/AspirePackageRestoreProvider.ts | 12 ++++++++++-- src/Aspire.Cli/Commands/StartCommand.cs | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/extension/src/utils/AspirePackageRestoreProvider.ts b/extension/src/utils/AspirePackageRestoreProvider.ts index b376253d3a8..62038ee67b9 100644 --- a/extension/src/utils/AspirePackageRestoreProvider.ts +++ b/extension/src/utils/AspirePackageRestoreProvider.ts @@ -64,10 +64,14 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { async retryRestore(): Promise { this._failedDirs.clear(); this._showProgress(); - await this._restoreAll(); + await this._restoreAll(true); } - private async _restoreAll(): Promise { + private async _restoreAll(force = false): Promise { + if (!force && !getEnableAutoRestore()) { + extensionLogOutputChannel.info('Auto-restore is disabled, skipping restore'); + return; + } const allConfigs = await findAspireSettingsFiles(); const configs = allConfigs.filter(uri => uri.fsPath.endsWith(aspireConfigFileName)); if (configs.length === 0) { @@ -122,6 +126,10 @@ export class AspirePackageRestoreProvider implements vscode.Disposable { } private async _restoreIfChanged(uri: vscode.Uri, isInitial: boolean): Promise { + if (!getEnableAutoRestore()) { + return; + } + let content: string; try { content = (await vscode.workspace.fs.readFile(uri)).toString(); diff --git a/src/Aspire.Cli/Commands/StartCommand.cs b/src/Aspire.Cli/Commands/StartCommand.cs index 733fc6b8a2e..b3315adeec3 100644 --- a/src/Aspire.Cli/Commands/StartCommand.cs +++ b/src/Aspire.Cli/Commands/StartCommand.cs @@ -6,6 +6,7 @@ using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; namespace Aspire.Cli.Commands; From c0ff601803826272bc413fa36d2122a0c131dea9 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 9 Apr 2026 02:34:21 -0400 Subject: [PATCH 5/5] Add context comment explaining why isExtensionHost is always false --- src/Aspire.Cli/Commands/StartCommand.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Cli/Commands/StartCommand.cs b/src/Aspire.Cli/Commands/StartCommand.cs index b3315adeec3..26bd747a9e3 100644 --- a/src/Aspire.Cli/Commands/StartCommand.cs +++ b/src/Aspire.Cli/Commands/StartCommand.cs @@ -46,8 +46,9 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell var isolated = parseResult.GetValue(AppHostLauncher.s_isolatedOption); var noBuild = parseResult.GetValue(s_noBuildOption); - // `aspire start` is always user-initiated, so we never suppress dashboard URLs - // in the summary — even when running inside the VS Code extension terminal. + // `aspire start` is always user-initiated — the VS Code extension only invokes + // `aspire run`, never `aspire start`. So we hardcode isExtensionHost to false + // to ensure dashboard URLs always appear in the summary output. var isExtensionHost = false; var waitForDebugger = parseResult.GetValue(RootCommand.WaitForDebuggerOption); var globalArgs = RootCommand.GetChildProcessArgs(parseResult);