Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions extension/src/utils/AspirePackageRestoreProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,14 @@ export class AspirePackageRestoreProvider implements vscode.Disposable {
async retryRestore(): Promise<void> {
this._failedDirs.clear();
this._showProgress();
await this._restoreAll();
await this._restoreAll(true);
}

private async _restoreAll(): Promise<void> {
private async _restoreAll(force = false): Promise<void> {
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) {
Expand Down Expand Up @@ -122,6 +126,10 @@ export class AspirePackageRestoreProvider implements vscode.Disposable {
}

private async _restoreIfChanged(uri: vscode.Uri, isInitial: boolean): Promise<void> {
if (!getEnableAutoRestore()) {
return;
}

let content: string;
try {
content = (await vscode.workspace.fs.readFile(uri)).toString();
Expand Down
19 changes: 18 additions & 1 deletion src/Aspire.Cli/Commands/AppHostLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,22 @@ private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, Can
return (dotnetPath, childArgs);
}

/// <summary>
/// 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.
/// </summary>
internal const string ExtensionEnvironmentVariablePrefix = "ASPIRE_EXTENSION_";

/// <summary>
/// Returns <see langword="true"/> if the specified environment variable name
/// should be removed from detached child CLI processes.
/// </summary>
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<LaunchResult> LaunchAndWaitForBackchannelAsync(
Expand All @@ -227,7 +243,8 @@ private async Task<LaunchResult> LaunchAndWaitForBackchannelAsync(
childProcess = DetachedProcessLauncher.Start(
executablePath,
childArgs,
executionContext.WorkingDirectory.FullName);
executionContext.WorkingDirectory.FullName,
IsExtensionEnvironmentVariable);
}
catch (Exception ex)
{
Expand Down
16 changes: 12 additions & 4 deletions src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,12 @@ protected override async Task<int> 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);
Expand Down Expand Up @@ -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<string> { appHostLabel, dashboardLabel, logsLabel };
// Calculate column width based on labels that will actually be displayed
var labels = new List<string> { appHostLabel, logsLabel };
if (!isExtensionHost)
{
labels.Add(dashboardLabel);
}
if (pid.HasValue)
{
labels.Add(pidLabel);
Expand Down
7 changes: 4 additions & 3 deletions src/Aspire.Cli/Commands/StartCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> s_noBuildOption = new("--no-build")
{
Expand All @@ -32,7 +31,6 @@ public StartCommand(
: base("start", StartCommandStrings.Description,
features, updateNotifier, executionContext, interactionService, telemetry)
{
_interactionService = interactionService;
_appHostLauncher = appHostLauncher;

Options.Add(s_noBuildOption);
Expand All @@ -48,7 +46,10 @@ protected override async Task<int> 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();
Expand Down
21 changes: 20 additions & 1 deletion src/Aspire.Cli/Processes/DetachedProcessLauncher.Unix.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
private static Process StartUnix(string fileName, IReadOnlyList<string> arguments, string workingDirectory)
private static Process StartUnix(string fileName, IReadOnlyList<string> arguments, string workingDirectory, Func<string, bool>? shouldRemoveEnvironmentVariable)
{
var startInfo = new ProcessStartInfo
{
Expand All @@ -33,6 +33,25 @@ private static Process StartUnix(string fileName, IReadOnlyList<string> 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<string>();
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");

Expand Down
116 changes: 93 additions & 23 deletions src/Aspire.Cli/Processes/DetachedProcessLauncher.Windows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ internal static partial class DetachedProcessLauncher
/// PROC_THREAD_ATTRIBUTE_HANDLE_LIST to prevent handle inheritance to grandchildren.
/// </summary>
[SupportedOSPlatform("windows")]
private static Process StartWindows(string fileName, IReadOnlyList<string> arguments, string workingDirectory)
private static Process StartWindows(string fileName, IReadOnlyList<string> arguments, string workingDirectory, Func<string, bool>? shouldRemoveEnvironmentVariable)
{
// Open NUL device for stdout/stderr — child writes go nowhere
using var nulHandle = CreateFileW(
Expand Down Expand Up @@ -88,34 +88,53 @@ private static Process StartWindows(string fileName, IReadOnlyList<string> 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
{
Expand Down Expand Up @@ -217,6 +236,57 @@ private static void AppendArgument(StringBuilder sb, string argument)
sb.Append('"');
}

/// <summary>
/// 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.
/// </summary>
[SupportedOSPlatform("windows")]
private static nint BuildFilteredEnvironmentBlock(Func<string, bool> shouldRemove)
{
// Collect current environment variables, excluding the ones to remove.
var envVars = new SortedDictionary<string, string>(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;
Expand Down
7 changes: 4 additions & 3 deletions src/Aspire.Cli/Processes/DetachedProcessLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,15 @@ internal static partial class DetachedProcessLauncher
/// <param name="fileName">The executable path (e.g. dotnet or the native CLI).</param>
/// <param name="arguments">The command-line arguments for the child process.</param>
/// <param name="workingDirectory">The working directory for the child process.</param>
/// <param name="shouldRemoveEnvironmentVariable">Optional predicate that returns <see langword="true" /> for environment variable names that should be removed from the child process.</param>
/// <returns>A <see cref="Process"/> object representing the launched child.</returns>
public static Process Start(string fileName, IReadOnlyList<string> arguments, string workingDirectory)
public static Process Start(string fileName, IReadOnlyList<string> arguments, string workingDirectory, Func<string, bool>? 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);
}
}
6 changes: 6 additions & 0 deletions src/Shared/KnownConfigNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,19 @@ 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";
public const string DcpDeveloperCertificate = "ASPIRE_DCP_USE_DEVELOPER_CERTIFICATE";

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
{
Expand Down
Loading
Loading