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/AppHostLauncher.cs b/src/Aspire.Cli/Commands/AppHostLauncher.cs index 47b6b1d042f..c38a791214d 100644 --- a/src/Aspire.Cli/Commands/AppHostLauncher.cs +++ b/src/Aspire.Cli/Commands/AppHostLauncher.cs @@ -212,6 +212,22 @@ private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, Can return (dotnetPath, childArgs); } + /// + /// 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. + /// + internal const string ExtensionEnvironmentVariablePrefix = "ASPIRE_EXTENSION_"; + + /// + /// Returns if the specified environment variable name + /// should be removed from detached child CLI processes. + /// + internal static bool IsExtensionEnvironmentVariable(string name) => + name.StartsWith(ExtensionEnvironmentVariablePrefix, StringComparison.OrdinalIgnoreCase); + private record LaunchResult(Process? ChildProcess, IAppHostAuxiliaryBackchannel? Backchannel, DashboardUrlsState? DashboardUrls, bool ChildExitedEarly, int ChildExitCode); private async Task LaunchAndWaitForBackchannelAsync( @@ -227,7 +243,8 @@ private async Task LaunchAndWaitForBackchannelAsync( childProcess = DetachedProcessLauncher.Start( executablePath, childArgs, - executionContext.WorkingDirectory.FullName); + executionContext.WorkingDirectory.FullName, + IsExtensionEnvironmentVariable); } 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..26bd747a9e3 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); @@ -48,7 +46,10 @@ 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 — 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); 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..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) + private static Process StartUnix(string fileName, IReadOnlyList arguments, string workingDirectory, Func? shouldRemoveEnvironmentVariable) { var startInfo = new ProcessStartInfo { @@ -33,6 +33,25 @@ 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 (shouldRemoveEnvironmentVariable is not null) + { + var keysToRemove = new List(); + foreach (var key in startInfo.Environment.Keys) + { + if (shouldRemoveEnvironmentVariable(key)) + { + keysToRemove.Add(key); + } + } + + foreach (var key in keysToRemove) + { + startInfo.Environment.Remove(key); + } + } + 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..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) + 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( @@ -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 (shouldRemoveEnvironmentVariable is not null) + { + envBlockHandle = BuildFilteredEnvironmentBlock(shouldRemoveEnvironmentVariable); + } + + 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,57 @@ 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(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 (!shouldRemove(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'); + } + + if (envVars.Count == 0) + { + 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..1110cd39f32 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 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) + public static Process Start(string fileName, IReadOnlyList arguments, string workingDirectory, Func? shouldRemoveEnvironmentVariable = null) { if (OperatingSystem.IsWindows()) { - return StartWindows(fileName, arguments, workingDirectory); + return StartWindows(fileName, arguments, workingDirectory, shouldRemoveEnvironmentVariable); } - return StartUnix(fileName, arguments, workingDirectory); + return StartUnix(fileName, arguments, workingDirectory, shouldRemoveEnvironmentVariable); } } 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..fbd59c9fb9e 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; @@ -1559,4 +1560,97 @@ 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."); + } + + [Fact] + public void DetachedChildEnvironmentFilter_PreservesDebugSessionVariables() + { + // 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)); + } + }