Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3ca253e
Fix explicit AppHost selection persistence
Aug 6, 2026
432bf3b
Track AppHost selection provenance
Aug 7, 2026
32ef402
Merge remote-tracking branch 'microsoft/main' into adamint/fix-19080-…
Aug 7, 2026
5187cca
Apply launch-configuration selection scope to every CLI command
Aug 7, 2026
87944ed
Keep launch-configuration scoping out of generated launch.json
Aug 7, 2026
59b3536
Let a launch configuration establish, but never replace, the default
Aug 7, 2026
8853a27
Read the recorded AppHost default the same way the CLI resolves it
Aug 7, 2026
c077e9d
Stop the AppHost selection-origin marker at the child-process boundary
Aug 7, 2026
6055987
Decide preservation on the recorded path's presence, not its resolution
Aug 7, 2026
60e8e12
Fail loudly if the hostile-path test escapes its temp workspace
Aug 7, 2026
c14c51c
Serialize the workspace default establish decision across processes
Aug 7, 2026
707b184
Extract the workspace config target into a typed result
Aug 7, 2026
e17b920
Case-fold the workspace config lock key on every platform
Aug 7, 2026
5ea6ec9
Read the recorded default through the canonical config reader
Aug 7, 2026
85f7f87
Assert the config target invariant across every resolution branch
Aug 7, 2026
dd53651
Merge remote-tracking branch 'upstream/main' into adamint/fix-19080-l…
Aug 7, 2026
1e7e43e
Ignore global AppHost paths for scoped defaults
adamint Aug 9, 2026
f67d92b
Validate recorded AppHost paths before resolving
adamint Aug 9, 2026
9b48ec8
Drop the invented tool name from the agent-selection doc
adamint Aug 9, 2026
76298d6
Compare recorded AppHost paths per platform and fold the lock key
adamint Aug 9, 2026
c6c56f0
Ask the volume, not the OS, whether two apphost paths differ only in …
adamint Aug 10, 2026
862fe6e
Compare launch-configuration paths by filesystem identity, not by pla…
adamint Aug 10, 2026
ff11485
Verify the workspace config lock names the config root actually resolved
adamint Aug 10, 2026
a04be5f
Treat a recorded path whose casing no longer exists as stale, not as …
adamint Aug 10, 2026
d5289f9
Preserve the root separator when rebuilding a parent for the casing p…
adamint Aug 10, 2026
2a50c1a
Skip the two-spelling casing test where the volume folds the spellings
adamint Aug 10, 2026
ec027f3
Bind workspace config target state
Aug 10, 2026
a013bc9
Merge branch 'main' into adamint/fix-19080-launch-config-persistence
adamint Aug 10, 2026
3b1164f
Keep launch default fix focused
Aug 10, 2026
8b7d906
Fix AppHost path identity handling
Aug 11, 2026
38517c3
Serialize workspace default establishment
Aug 11, 2026
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
2 changes: 2 additions & 0 deletions extension/src/dcp/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as vscode from 'vscode';
import type { AspireDebugSession, DashboardLaunchBehavior } from '../debugger/AspireDebugSession';
import { appHostSelectionOriginConfigKey, type AppHostSelectionOrigin } from '../debugger/AspireDebugConfigurationMetadata';

export interface ErrorResponse {
error: ErrorDetails;
Expand Down Expand Up @@ -209,6 +210,7 @@ export interface AspireExtendedDebugConfiguration extends vscode.DebugConfigurat
step?: string;
skipCliAvailabilityCheck?: boolean;
env?: { [key: string]: string };
[appHostSelectionOriginConfigKey]?: AppHostSelectionOrigin;
}

interface AspireDebuggersConfiguration {
Expand Down
14 changes: 14 additions & 0 deletions extension/src/debugger/AspireDebugConfigurationMetadata.ts
Original file line number Diff line number Diff line change
@@ -1 +1,15 @@
export const appHostTelemetryTargetPathConfigKey = '__aspireAppHostTelemetryTargetPath';

// This internal field survives VS Code's two debug-configuration resolver stages so the
// eventual CLI process can distinguish a launch.json-owned target from a persisted default.
export const appHostSelectionOriginConfigKey = '__aspireAppHostSelectionOrigin';

/**
* Who chose the AppHost this session launches.
*
* The CLI decides from this value whether the target may become the workspace default recorded in
* `aspire.config.json`. `user-selection` and `default-discovery` are statements about the project;
* `explicit-launch-configuration` (a `launch.json` entry naming a specific target) is scoped to the
* one invocation and must never replace a default the user already has.
*/
export type AppHostSelectionOrigin = 'explicit-launch-configuration' | 'default-discovery' | 'user-selection';
61 changes: 52 additions & 9 deletions extension/src/debugger/AspireDebugConfigurationProvider.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import * as vscode from 'vscode';
import { defaultConfigurationName } from '../loc/strings';
import type { AspireExtendedDebugConfiguration } from '../dcp/types';
import { AppHostDiscoveryService, getDebugTargetForCandidate } from '../utils/appHostDiscovery';
import { AppHostDiscoveryService, getDebugTargetForCandidate, isSamePath } from '../utils/appHostDiscovery';
import type { CandidateAppHostDisplayInfo } from '../utils/appHostDiscovery';
import { checkCliAvailableOrRedirect } from '../utils/workspace';
import { extensionLogOutputChannel } from '../utils/logging';
import { appHostTelemetryTargetPathConfigKey } from './AspireDebugConfigurationMetadata';
import { appHostSelectionOriginConfigKey, appHostTelemetryTargetPathConfigKey } from './AspireDebugConfigurationMetadata';

export class AspireDebugConfigurationProvider implements vscode.DebugConfigurationProvider {
constructor(private readonly _appHostDiscoveryService: AppHostDiscoveryService) {
constructor(
private readonly _appHostDiscoveryService: AppHostDiscoveryService,
// VS Code writes the configurations returned by an `Initial`-kind provider verbatim into a
// newly created launch.json, while `Dynamic`-kind configurations stay ephemeral. Only the
// ephemeral ones may carry the internal selection-origin marker: persisting it would bake a
// stale provenance into a user-owned file and permanently defeat the launch-configuration
// scoping this marker exists to enable. See https://github.com/microsoft/aspire/issues/19080.
private readonly _triggerKind: vscode.DebugConfigurationProviderTriggerKind = vscode.DebugConfigurationProviderTriggerKind.Dynamic) {
}

async provideDebugConfigurations(folder: vscode.WorkspaceFolder | undefined, token?: vscode.CancellationToken): Promise<vscode.DebugConfiguration[]> {
Expand All @@ -31,16 +38,17 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati
return [this.createDefaultConfiguration(folder)];
}

return [{
return [this.withProvidedSelectionOrigin({
type: 'aspire',
request: 'launch',
name: defaultConfigurationName,
program: getDebugTargetForCandidate(candidate)
}];
program: getDebugTargetForCandidate(candidate),
})];
}

async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise<vscode.DebugConfiguration | null | undefined> {
const aspireConfig = config as AspireExtendedDebugConfiguration;
this.ensureAppHostSelectionOrigin(aspireConfig);
if (!aspireConfig.skipCliAvailabilityCheck) {
const result = await checkCliAvailableOrRedirect('debug_gate');
if (!result.available) {
Expand Down Expand Up @@ -69,10 +77,19 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati

async resolveDebugConfigurationWithSubstitutedVariables(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, token?: vscode.CancellationToken): Promise<vscode.DebugConfiguration | null | undefined> {
const aspireConfig = config as AspireExtendedDebugConfiguration;
this.ensureAppHostSelectionOrigin(aspireConfig);
delete aspireConfig.skipCliAvailabilityCheck;

if (typeof config.program === 'string') {
const program = config.program;
if (aspireConfig[appHostSelectionOriginConfigKey] === 'explicit-launch-configuration' && this.isWorkspaceFolderRoot(program, folder)) {
// Only a program pointing at the workspace folder root delegates the choice back to
// normal discovery, which is what the extension's own default configuration does. A
// configuration naming a specific AppHost file *or* subdirectory is scoped to that
// target and must not become the workspace default.
aspireConfig[appHostSelectionOriginConfigKey] = 'default-discovery';
}

config.program = await this.resolveDebugTarget(program, folder);

const telemetryTarget = await this.tryFindWorkspaceDefaultCandidate(program, folder);
Expand Down Expand Up @@ -118,11 +135,37 @@ export class AspireDebugConfigurationProvider implements vscode.DebugConfigurati
}

private createDefaultConfiguration(folder: vscode.WorkspaceFolder): vscode.DebugConfiguration {
return {
return this.withProvidedSelectionOrigin({
type: 'aspire',
request: 'launch',
name: defaultConfigurationName,
program: folder.uri.fsPath
};
program: folder.uri.fsPath,
});
}

private withProvidedSelectionOrigin(config: vscode.DebugConfiguration): vscode.DebugConfiguration {
if (this._triggerKind !== vscode.DebugConfigurationProviderTriggerKind.Dynamic) {
// Leave the marker off so resolve-time classification runs against whatever the user
// ends up with in launch.json rather than against provenance frozen at creation time.
return config;
}

return { ...config, [appHostSelectionOriginConfigKey]: 'default-discovery' };
}

private isWorkspaceFolderRoot(program: string, folder: vscode.WorkspaceFolder | undefined): boolean {
const owningFolder = folder ?? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(program));

return owningFolder !== undefined && isSamePath(program, owningFolder.uri.fsPath);
}

private ensureAppHostSelectionOrigin(config: AspireExtendedDebugConfiguration): void {
if (config[appHostSelectionOriginConfigKey]) {
return;
}

config[appHostSelectionOriginConfigKey] = config.program
? 'explicit-launch-configuration'
: 'default-discovery';
}
}
22 changes: 16 additions & 6 deletions extension/src/debugger/AspireDebugSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { classifyAppHostPath, classifyAppHostDirectory } from "../utils/appHostL
import { bucketAspireCommand } from "../utils/telemetryBuckets";
import { getAppHostTargetVersion } from "../utils/appHostTargetVersion";
import type { AspireDebugConsoleOutputEvent } from "../types/extensionApi";
import { appHostTelemetryTargetPathConfigKey } from "./AspireDebugConfigurationMetadata";
import { appHostSelectionOriginConfigKey, appHostTelemetryTargetPathConfigKey } from "./AspireDebugConfigurationMetadata";

export type DashboardLaunchBehavior = 'none' | 'notification' | DashboardBrowserType;
export type DashboardBrowserType = 'openExternalBrowser' | 'integratedBrowser' | 'debugChrome' | 'debugEdge' | 'debugFirefox';
Expand Down Expand Up @@ -236,6 +236,7 @@ export class AspireDebugSession implements vscode.DebugAdapter {
const appHostTelemetryTargetPath = typeof this._session.configuration[appHostTelemetryTargetPathConfigKey] === 'string'
? this._session.configuration[appHostTelemetryTargetPathConfigKey]
: undefined;
const appHostSelectionOrigin = this.configuration[appHostSelectionOriginConfigKey];
const extensionArgs: string[] = [];
// Telemetry: emit `debug/apphost/start` once per AppHost launch. This must
// happen before any awaited filesystem metadata work because child
Expand Down Expand Up @@ -307,13 +308,13 @@ export class AspireDebugSession implements vscode.DebugAdapter {
if (appHostIsDirectory) {
this.sendMessageWithEmoji("📁", launchingWithDirectory(sessionType, appHostPath));

void this.spawnAspireCommand(args, appHostPath, noDebug, commandLabel);
void this.spawnAspireCommand(args, appHostPath, noDebug, commandLabel, this.getAppHostSelectionOriginEnvironment(appHostSelectionOrigin));
}
else {
this.sendMessageWithEmoji("📂", launchingWithAppHost(sessionType, appHostPath));

const workspaceFolder = path.dirname(appHostPath);
void this.spawnAspireCommand(args, workspaceFolder, noDebug, commandLabel);
void this.spawnAspireCommand(args, workspaceFolder, noDebug, commandLabel, this.getAppHostSelectionOriginEnvironment(appHostSelectionOrigin));
}
}

Expand Down Expand Up @@ -355,7 +356,13 @@ export class AspireDebugSession implements vscode.DebugAdapter {
return this._appHostTargetVersionAtLaunch;
}

async spawnAspireCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean, commandLabel: string = 'aspire run') {
private getAppHostSelectionOriginEnvironment(selectionOrigin: AspireExtendedDebugConfiguration[typeof appHostSelectionOriginConfigKey]): EnvVar[] | undefined {
return selectionOrigin
? [{ name: EnvironmentVariables.ASPIRE_CLI_APPHOST_SELECTION_ORIGIN, value: selectionOrigin }]
: undefined;
Comment thread
adamint marked this conversation as resolved.
}

async spawnAspireCommand(args: string[], workingDirectory: string | undefined, noDebug: boolean, commandLabel: string = 'aspire run', internalEnv?: EnvVar[]) {
const disposable = this._rpcServer.onNewConnection((client: ICliRpcClient) => {
if (client.debugSessionId === this.debugSessionId) {
this._rpcClient = client;
Expand All @@ -366,7 +373,10 @@ export class AspireDebugSession implements vscode.DebugAdapter {
const configuredEnv = this.configuration.env;
const env = configuredEnv
? Object.entries(configuredEnv).map(([name, value]) => ({ name, value: String(value) }))
: undefined;
: [];
if (internalEnv) {
env.push(...internalEnv);
}

// Per-stream line buffers. CLI stdio chunks aren't guaranteed to arrive aligned to line
// boundaries; without buffering, partial lines (and split-point ANSI sequences) would be
Expand Down Expand Up @@ -429,7 +439,7 @@ export class AspireDebugSession implements vscode.DebugAdapter {
workingDirectory: workingDirectory,
debugSessionId: this.debugSessionId,
noDebug: noDebug,
env: env
env: env.length > 0 ? env : undefined
},
);

Expand Down
9 changes: 5 additions & 4 deletions extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,12 +345,13 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(cliUpdateCommandRegistration, cliUpdateSelfCommandRegistration, settingsCommandRegistration, openLocalSettingsCommandRegistration, openGlobalSettingsCommandRegistration, runAppHostCommandRegistration, debugAppHostCommandRegistration);
context.subscriptions.push(installCliRegistration, verifyCliInstalledRegistration);

const debugConfigProvider = new AspireDebugConfigurationProvider(appHostDiscoveryService);
const dynamicDebugConfigProvider = new AspireDebugConfigurationProvider(appHostDiscoveryService, vscode.DebugConfigurationProviderTriggerKind.Dynamic);
const initialDebugConfigProvider = new AspireDebugConfigurationProvider(appHostDiscoveryService, vscode.DebugConfigurationProviderTriggerKind.Initial);
context.subscriptions.push(
vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic)
vscode.debug.registerDebugConfigurationProvider('aspire', dynamicDebugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Dynamic)
);
context.subscriptions.push(
vscode.debug.registerDebugConfigurationProvider('aspire', debugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Initial)
vscode.debug.registerDebugConfigurationProvider('aspire', initialDebugConfigProvider, vscode.DebugConfigurationProviderTriggerKind.Initial)
);

context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory('aspire', new AspireDebugAdapterDescriptorFactory(rpcServer, dcpServer, terminalProvider, aspireExtensionContext.addAspireDebugSession.bind(aspireExtensionContext), aspireExtensionContext.removeAspireDebugSession.bind(aspireExtensionContext))));
Expand All @@ -363,7 +364,7 @@ export async function activate(context: vscode.ExtensionContext) {
getAspireDebugSession: aspireExtensionContext.getAspireDebugSession.bind(aspireExtensionContext),
}));

aspireExtensionContext.initialize(rpcServer, context, debugConfigProvider, dcpServer, terminalProvider, editorCommandProvider);
aspireExtensionContext.initialize(rpcServer, context, dynamicDebugConfigProvider, dcpServer, terminalProvider, editorCommandProvider);

// Register Aspire MCP server definition provider so the Aspire MCP server
// appears automatically in VS Code's MCP tools list for Aspire workspaces.
Expand Down
2 changes: 2 additions & 0 deletions extension/src/server/interactionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AspireExtendedDebugConfiguration, EnvVar } from '../dcp/types';
import { AnsiColors } from '../utils/AspireTerminalProvider';
import { AspireDebugSession } from '../debugger/AspireDebugSession';
import type { DashboardLaunchBehavior } from '../debugger/AspireDebugSession';
import { appHostSelectionOriginConfigKey } from '../debugger/AspireDebugConfigurationMetadata';
import { isDirectory } from '../utils/io';
import { sendTelemetryEvent } from '../utils/telemetry';
import { dashboardDefaultChangedNotificationKey } from '../utils/dashboardNotificationState';
Expand Down Expand Up @@ -674,6 +675,7 @@ export class InteractionService implements IInteractionService {
command: command as AspireExtendedDebugConfiguration['command'],
args: options?.args,
noDebug: !debug,
[appHostSelectionOriginConfigKey]: projectFile ? 'user-selection' : 'default-discovery',
};

const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(workingDirectory));
Expand Down
4 changes: 3 additions & 1 deletion extension/src/services/AppHostLaunchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as path from 'path';
import * as fs from 'fs';
import * as vscode from 'vscode';
import { AspireCommandType, AspireExtendedDebugConfiguration } from '../dcp/types';
import { appHostSelectionOriginConfigKey } from '../debugger/AspireDebugConfigurationMetadata';
import { startDebuggingDeclined } from '../loc/strings';
import { classifyAppHostDirectory, classifyAppHostPath } from '../utils/appHostLanguage';
import { classifyError, isCommandCancellation, sendTelemetryEvent, type EventProperties } from '../utils/telemetry';
Expand Down Expand Up @@ -148,7 +149,8 @@ export class AppHostLaunchService implements vscode.Disposable {
request: 'launch',
program: appHostPath,
command,
noDebug
noDebug,
[appHostSelectionOriginConfigKey]: 'user-selection',
};

if (doStep) {
Expand Down
Loading
Loading