Let agents start and stop AppHosts through VS Code - #19134
Let agents start and stop AppHosts through VS Code#19134Adam Ratzman (adamint) merged 60 commits into
Conversation
Agents currently have no way to start or stop an Aspire AppHost through the editor. They fall back to a detached `aspire run`, which produces a process the editor cannot observe, cannot attach a debugger to, and cannot shut down together with its child resources. Contribute two language model tools, `aspire_apphost_start` and `aspire_apphost_stop`, backed by the extension's existing editor-owned lifecycle (`AppHostLaunchService.launch` and `AspireDebugSession.stopDebugging`). There is deliberately no CLI fallback and no public extension API surface: an agent can only reach the same session a user would start themselves. The tool service is the single place that decides whether an agent request may touch lifecycle state. It canonicalizes the workspace-relative path (rejecting missing, ambiguous, outside-workspace, symlink-escaped, and non-AppHost targets), refuses to run in an untrusted workspace, serializes work per canonical AppHost path so concurrent model calls cannot start two processes, and returns a bounded JSON result that never carries stderr, environment, dashboard URLs, credentials, or absolute paths. Stop only targets a matching editor-owned session; an AppHost started outside the editor is reported as `notEditorOwned` rather than terminated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffeff87e-f284-434d-87d3-843e21a7aebb
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffeff87e-f284-434d-87d3-843e21a7aebb
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffeff87e-f284-434d-87d3-843e21a7aebb
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffeff87e-f284-434d-87d3-843e21a7aebb
Review follow-ups on the AppHost lifecycle language model tools: `AppHostLaunchService.launch` is the editor's own run/debug path and its errors reach the user through `showErrorMessage`, so the lifecycle lock timeout can no longer carry a hard-coded English message. It now uses a localized string registered in both `strings.ts` and `package.nls.json`. The tool model descriptions were duplicated into `strings.ts` even though only the `package.json` `%key%` references are read, and `AppHostDataRepository.allKnownAppHosts` was added but never called. Both are removed so the localization bundle and the repository surface only carry what is actually used. Adds coverage for the localized busy message on the editor launch path and for the fail-closed behavior when the external-ownership probe cannot answer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5cf26956-2710-4f8d-b234-b4f6291b9d56
The confirmation dialog is the only thing standing between a model-supplied
path and a debug session, so what it renders has to be what actually runs.
sanitizeModelSuppliedText stripped C0 controls and Markdown metacharacters
but left Unicode format characters alone. Those are neither `\s` nor C0, so
a bidi isolate/override run could reorder the displayed path while the rest
of the prompt looked untouched, and zero-width characters could split a name
the user would otherwise recognize. Strip `\p{Cf}` as its own pass.
describeModelSuppliedPath also echoed the raw model string whenever the path
could not be mapped into an open workspace folder. resolveTarget rejects
those calls anyway, so echoing only handed the model free-form prose inside
the trusted prompt that gates "Always allow". Render a fixed localized
placeholder instead.
Also capture a screenshot of the consent modal during the E2E run so the
approval surface is visually verifiable.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5cf26956-2710-4f8d-b234-b4f6291b9d56
Address defects found reviewing the AppHost lifecycle language model tools.
Every one of these produced a wrong answer for a valid request rather than a
crash, so they were invisible without targeted tests.
The AppHost content gate used a naive quote/comment scanner. Any C# verbatim
string containing a backslash-quote (`@"C:\data\"`, i.e. a normal Windows bind
mount path), any raw string literal, and any JS/TS regex literal desynchronized
the scanner and blanked the rest of the file, so a perfectly valid AppHost was
reported as `notAnAppHost`. Replace it with a single language-aware pass that
understands `$`/`@` prefixes, raw-string fences, verbatim escapes, char
literals, template literals, and regex-vs-division. Every failure mode of a
scanner like this is a false negative, never a way to smuggle a marker past the
gate, so the strict runnable-shape requirement is unchanged.
`Aspire: Configure launch.json` writes `program: '${workspaceFolder}'`, so for
the standard configure-then-F5 flow a session's `appHostPath` is a directory and
could never match a requested AppHost file. Carry the AppHost the configuration
provider already resolved for that folder on the session and match on it too.
That value is only populated for an unambiguous single-candidate folder, so it
stays an exact identity signal; a multi-AppHost folder still reports
`notEditorOwned` rather than guessing which one to stop.
The lifecycle lock key was derived by scanning existing keys for a match, but
that relation is not transitive: `AppHost.csproj` matches both a sibling
`apphost.cs` and a sibling `Program.cs` while those two do not match each other,
so `Map` insertion order decided whether two callers shared a lock. Make the key
a pure function of the directory. Also bound how long the lock may be held, so a
wedged operation cannot disable an AppHost's lifecycle until the window is
reloaded.
Finally, move the `aspire ps` probe out of the lock. It queries each AppHost
over its backchannel, which is slowest exactly when an AppHost is paused at a
breakpoint - the case these tools exist to protect - and holding a lock with a
10s wait budget across it made the user's own Run/Debug fail with `busy`. On the
stop path a probe failure now reports `notRunning` instead of `failed`, since
there is nothing to stop either way and `failed` would push the agent to the CLI.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5cf26956-2710-4f8d-b234-b4f6291b9d56
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 19134Or
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 19134" |
…vscode-agent-lifecycle # Conflicts: # extension/src/debugger/AspireDebugSession.ts
There was a problem hiding this comment.
Pull request overview
Adds VS Code language-model tools for safely starting and stopping workspace AppHosts through editor-owned debug sessions.
Changes:
- Adds start/stop tools with validation, confirmation, ownership checks, and lifecycle locking.
- Extends AppHost session tracking and detection.
- Adds localization, unit tests, and end-to-end coverage.
Show a summary per file
| File | Description |
|---|---|
extension/src/views/AppHostDataRepository.ts |
Adds cancellable, resource-free AppHost queries. |
extension/src/utils/appHostLanguage.ts |
Adds source-file AppHost validation. |
extension/src/types/extensionApi.ts |
Adds E2E tool-control commands. |
extension/src/testing/e2eStateFileBridge.ts |
Exposes tool preparation and invocation to E2E tests. |
extension/src/test/testRunSessionManager.test.ts |
Verifies test-session classification. |
extension/src/test/appHostLifecycleTools.test.ts |
Tests lifecycle tools and security boundaries. |
extension/src/test/appHostLaunchService.test.ts |
Tests locking and session identity. |
extension/src/test/appHostLanguage.test.ts |
Tests source classification. |
extension/src/test-e2e/helpers/vscode.ts |
Adds modal-dialog automation. |
extension/src/test-e2e/helpers/extester.ts |
Exposes Extester modal APIs. |
extension/src/test-e2e/appHostLifecycleTools.e2e.test.ts |
Exercises real start/stop tool flows. |
extension/src/services/AppHostLaunchService.ts |
Adds shared lifecycle locking and ownership providers. |
extension/src/loc/strings.ts |
Adds localized runtime messages. |
extension/src/lm/appHostLifecycleTools.ts |
Implements the lifecycle tools. |
extension/src/extension.ts |
Registers and wires the tools. |
extension/src/debugger/AspireDebugSession.ts |
Tracks operation and resolved AppHost identity. |
extension/src/dcp/types.ts |
Defines operation kinds. |
extension/src/dcp/TestRunSessionManager.ts |
Marks leased sessions as tests. |
extension/README.md |
Documents agent chat tools. |
extension/package.nls.json |
Adds manifest localization strings. |
extension/package.json |
Contributes and activates the tools. |
extension/loc/xlf/aspire-vscode.xlf |
Updates the localization catalog. |
Review details
Suppressed comments (2)
extension/src/lm/appHostLifecycleTools.ts:715
- In every multi-root workspace, a relative input produces the fixed “unresolved path” confirmation because this branch only constructs a candidate when there is exactly one folder.
resolveTarget, however, probes all workspace folders and will execute the file when exactly one exists. That lets a tool launch an AppHost the user was never shown; prepare should resolve and display a workspace-qualified target, or invocation must reject inputs that could not be identified in the confirmation.
? path.isAbsolute(requestedPath)
? path.resolve(requestedPath)
: workspaceFolders.length === 1
? path.resolve(workspaceFolders[0].uri.fsPath, requestedPath)
: undefined
extension/src/services/AppHostLaunchService.ts:184
- This identity comparison is case-sensitive on macOS because
getComparisonKeyonly folds case on Windows. On the default case-insensitive macOS filesystem, differently cased input resolves to the same AppHost file but will not match the editor session oraspire psresult, so duplicate prevention can start a second process. Canonicalize existing paths withrealpathor apply the same Darwin comparison policy used byappHostDiscovery.ts:1192-1200.
isSameAppHostIdentity(left: string | undefined, right: string | undefined): boolean {
if (!left || !right) {
return false;
}
return isMatchingAppHostPath(path.resolve(left), path.resolve(right));
- Files reviewed: 22/22 changed files
- Comments generated: 5
- Review effort level: Balanced
There was a problem hiding this comment.
Review details
Suppressed comments (5)
extension/src/lm/appHostLifecycleTools.ts:690
- The confirmation path is lossy: the sanitizer removes valid filename characters above and truncates the remainder here, while invocation still executes the original full path. Two files can therefore produce the same displayed approval target (the bidi/zero-width test already exercises this mismatch). Reject paths that cannot be represented within the bound or escape them losslessly so the confirmed identity exactly matches the executed file.
return singleLine.length > maxLength ? `${singleLine.slice(0, maxLength)}…` : singleLine;
extension/src/utils/appHostLanguage.ts:259
- These regexes search
withoutComments, which preserves all string literals. For example,const note = "from '@aspire/hosting'"plus executablecreateBuilder()/build().run()markers passes without importing Aspire, so an arbitrary JS/TS file can get through this execution gate. Parse actual import/require syntax rather than matching module-looking text inside arbitrary literals.
function referencesAspireModule(contents: string): boolean {
const moduleSpecifiers = [
...contents.matchAll(/\bfrom\s*(["'])(?<specifier>[^"']+)\1/g),
...contents.matchAll(/\brequire\s*\(\s*(["'])(?<specifier>[^"']+)\1\s*\)/g),
];
extension/src/services/AppHostLaunchService.ts:256
- This key collapses every C# project/source target in one directory into the same lock, even though
isSameAppHostIdentitytreats siblingFirst.csprojandSecond.csprojas distinct and launching state tracks them independently. A long operation on one sibling can make operations on the other time out asbusy. Use a canonical per-AppHost identity/alias mapping rather than the directory as the key.
return isProjectFile(resolvedPath) || isSourceFile(resolvedPath)
? `${getComparisonKey(path.dirname(resolvedPath))}${path.sep}`
: getComparisonKey(resolvedPath);
extension/src/lm/appHostLifecycleTools.ts:372
- The tool contract and manifest require a workspace-relative path, but this branch accepts an absolute path whenever it falls inside a workspace. Reject absolute inputs instead of silently normalizing them to the documented relative form.
const candidates = path.isAbsolute(requestedPath)
? [path.resolve(requestedPath)]
: workspaceFolders.map(folder => path.resolve(folder.uri.fsPath, requestedPath));
extension/src/utils/appHostLanguage.ts:243
withoutCommentsretains raw-string contents, so a multiline raw string with#:sdk Aspire.AppHost.Sdk...at the start of one of its lines satisfies this check even though the file has no SDK directive. Use the view that blanks literals; real directives remain executable text.
return /^[ \t]*#:sdk[ \t]+Aspire\.AppHost\.Sdk\b/m.test(withoutComments)
- Files reviewed: 22/22 changed files
- Comments generated: 3
- Review effort level: Balanced
Address review feedback on the AppHost start/stop language model tools.
Confirmation integrity: the path shown in the confirmation prompt is now
one-to-one with the path invoke executes. Identity-changing format
characters (C0/C1, DEL, Unicode Cf including zero-width and bidi controls)
are rejected instead of stripped, printable Markdown metacharacters are
escaped so they render literally, and over-long paths are refused rather
than elided. describeTarget now resolves the same target invoke will use,
so a relative path in a multi-root workspace is confirmed with its
workspace-folder-qualified identity instead of "an unresolved path".
Module reference gate: referencesAspireModule no longer matches a module
specifier that only appears inside a string or regex literal. The scanner
records literal spans and the gate requires genuine import/require syntax
immediately before the specifier, so a file containing
const doc = "require('aspire')" is no longer launchable as arbitrary code.
The C# #:sdk directive check moved to the executable view for the same
reason.
Contract conformance: absolute appHostPath input is rejected with
invalidInput in both start and stop, matching the manifest and README,
instead of being accepted when it happens to land inside a workspace.
AppHost identity: a new appHostIdentity module is the single source of
truth for whether two paths name the same AppHost. It returns
same/different/ambiguous, prefers the authoritative resolvedAppHostPath
when a session has one, and refuses to guess when a directory holds more
than one project or source candidate. The lifecycle lock key no longer
collapses to the containing directory, so sibling AppHost projects no
longer serialize against each other, and ownership lookups report
ambiguity rather than terminating or claiming the wrong session.
Ownership probe correctness: start discards the pre-lock aspire ps result
and revalidates ownership inside the lifecycle lock immediately before
launching, closing the window where an AppHost started from a terminal
during the wait allowed a duplicate launch. A failed probe now yields an
explicit unknown state and a failed outcome instead of collapsing into
notRunning/none.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (2)
extension/src/lm/appHostLifecycleTools.ts:330
- If the pre-lock
getRunningAppHostsprobe throws, control reaches this catch before any editor-owned session or launch exists, yet the result reportsownership: 'editor'. That contradicts the ownership contract, which definesunknownfor a failed probe, and can mislead the agent into believing the editor owns a process. Handle ownership-probe failures separately asfailed/unknown; the existing test that expectseditorfor this case should be corrected too.
catch (error) {
return this.createErrorResult(aspireAppHostStartToolName, error, preflight.target.relativePath, 'editor', requestedMode, undefined);
extension/src/lm/appHostLifecycleTools.ts:377
externalOwnershipBeforeLockcan be stale after waiting up to 10 seconds for the lifecycle lock. If a terminal starts this AppHost during the wait, the cachednonemakes the tool returnnotRunning; if it exits, cachedexternalreturnsnotEditorOwned. Revalidate external ownership after the lock wait, or restructure the flow so the pre-lock probe is only a fast path and never an authoritative result.
if (owned.sessions.length === 0) {
const externalOwnership = externalOwnershipBeforeLock
?? await this.probeExternalOwnershipForStop(current.absolutePath, token);
- Files reviewed: 23/23 changed files
- Comments generated: 2
- Review effort level: Balanced
The confirmation-escaping test created a fixture directory containing '*', which Win32 forbids in a file name, so the Windows extension unit test job failed in mkdirSync before reaching the assertion. Use metacharacters that are legal on both platforms and exercise '*' only off Windows, where a real path can actually contain one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (7)
extension/src/utils/appHostIdentity.ts:44
- This key is only lexically normalized, although
resolveTargetaccepts symlinks whose targets stay in the workspace. A launch through such a symlink and a later call through the canonical path get different keys; because both paths are project files,compareAppHostIdentityalso returnsdifferent. That permits a duplicate launch and prevents stop from finding the editor-owned session. Canonicalize existing paths withrealpathSync.native, falling back to the lexical path only when resolution fails.
export function getAppHostPathComparisonKey(value: string): string {
const resolved = path.normalize(path.resolve(value));
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
extension/src/lm/appHostLifecycleTools.ts:377
- The pre-lock
noneresult can be up to 10 seconds stale by the time this branch runs. If a terminal starts the AppHost while this call waits for the lifecycle lock, this returnsnotRunninginstead of the requirednotEditorOwned; the inverse race reports an already-exited process as external. As withstart, only use a positive pre-lock result as an early exit and re-probe after the lock when the earlier result was negative.
if (owned.sessions.length === 0) {
const externalOwnership = externalOwnershipBeforeLock
?? await this.probeExternalOwnershipForStop(current.absolutePath, token);
extension/src/lm/appHostLifecycleTools.ts:330
- A failure in the pre-lock external-ownership probe lands here even when no editor session exists, but the result is labeled
ownership: 'editor'. That contradicts the declared ownership contract (unknownmeans the probe failed) and can mislead the agent about who owns the process. Distinguish probe failures from failures after an editor-owned launch attempt and returnunknownfor the former.
This issue also appears on line 375 of the same file.
catch (error) {
return this.createErrorResult(aspireAppHostStartToolName, error, preflight.target.relativePath, 'editor', requestedMode, undefined);
extension/src/lm/appHostLifecycleTools.ts:843
startsWith('..')also rejects valid contained paths whose first component merely begins with two dots, such as..cache/AppHost.csproj. Those paths are inside the workspace but are reported as outside it. Check for the exact parent component (relative === '..'or a..${path.sep}prefix) instead.
function isContainedIn(folderPath: string, candidate: string): boolean {
const relative = path.relative(folderPath, candidate);
return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative);
}
extension/src/lm/appHostLifecycleTools.ts:781
- Ampersands also change rendered Markdown through entity decoding. For example, a real path
foo©/AppHost.csprojis displayed asfoo©/AppHost.csproj, so the confirmation is still not one-to-one with the executed target. Escape&along with the other Markdown metacharacters.
function escapeMarkdown(value: string): string {
return value.replace(/[\\`*_[\]()<>#+~|!]/g, character => `\\${character}`);
extension/src/utils/appHostLanguage.ts:247
- A UTF-8 BOM is decoded as
U+FEFF, so a valid single-file C# AppHost beginning with the normal BOM followed by#:sdkfails this anchored match and is rejected asnotAnAppHost. Strip one leading BOM from the executable view before testing the directive.
return /^[ \t]*#:sdk[ \t]+Aspire\.AppHost\.Sdk\b/m.test(executable)
extension/src/utils/appHostLanguage.ts:218
- This accepts source files by extension, but the current CLI handlers only recognize the exact names
apphost.cs,apphost.ts, andapphost.mts(DefaultLanguageDiscovery.cs:25,32andDotNetAppHostProject.cs:150-157). A marker-completeOther.csorapphost.jstherefore passes this gate, yet the CLI rejects it after VS Code has started the debug session, while the tool can already reportstarted. Restrict targets to filenames supported by the installed CLI (preferably via capabilities) rather than all parser extensions.
export function isSupportedAppHostFileExtension(filePath: string): boolean {
const extension = extname(filePath).toLowerCase();
return appHostProjectFileExtensions.includes(extension)
|| appHostCSharpSourceExtensions.includes(extension)
|| appHostJsTsSourceExtensions.includes(extension);
- Files reviewed: 23/23 changed files
- Comments generated: 1
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
…ools Address the second round of review feedback. Regex context: the `/` disambiguation resolved `)` and `}` from the previous character alone, so `if (x) /createBuilder().build().run()/.test(s)` left the regex body in the executable view and its contents satisfied the AppHost marker checks. The scanner now tracks what each open bracket started, so a regex after a control-statement head or a block is recognized and blanked while genuine division after a call or an object literal still reads as code. Symlink identity: identity and lifecycle-lock keys were only lexically normalized, so an in-workspace symlink and its target compared as different AppHosts and the session, launching-flag, and lock checks all missed, starting a duplicate process. Keys now canonicalize existing paths through realpath and fall back to the lexical form for paths that do not exist. Single-file invariant: the tool accepted any `.cs` file whose contents carried the SDK directive, but the launcher only treats a file named `apphost.cs` with no sibling `.csproj` as a single-file AppHost (IsValidSingleFileAppHost in src/Aspire.Cli/Projects/DotNetAppHostProject.cs). With a sibling project it rejects the source and searches for a project instead, so the confirmed path was not the one that would run. The gate now mirrors the launcher's rule and the caller must name the project file in that shape. README and the tool manifest say so. The ambiguity tests move the unprovable association to the session side, which is where it now arises, since a source file beside a project can no longer be tool input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (5)
extension/src/lm/appHostLifecycleTools.ts:537
AppHostDiscoveryService.discover()returns candidates of every status, while the existing tree only exposes candidates whose status isbuildable(AppHostDataRepository.ts:753-756). As written, an entry such aspossibly-unbuildableis included inknownAppHostsand can be selected and launched, contradicting this tool's buildable-only contract. Filter the discovery result here and add a non-buildable-candidate regression test.
const candidatesByFolder = await Promise.all(workspaceFolders.map(async folder => ({
folder,
candidates: await this._dependencies.discoveryService.discover(folder, false, token),
})));
extension/src/lm/appHostLifecycleTools.ts:560
- Workspace folder names are not guaranteed to be unique. If two roots have the same
folder.nameand relative AppHost path, this produces identicaldisplayPathvalues; resolution then returnsambiguousAppHostwith duplicateknownAppHosts, leaving no selector that can address either target. Disambiguate duplicate root names with a stable unique qualifier and use it consistently in the schema text, README, confirmation, and tests.
const displayPath = workspaceFolders.length > 1
? `${folder.name}/${relativePath}`
: relativePath;
extension/src/lm/appHostLifecycleTools.ts:868
- This containment check also rejects valid children whose first path segment merely starts with
.., such as<workspace>/..services/AppHost.csproj. Check for the parent segment itself (or..${path.sep}) instead so valid discovered AppHosts are not silently dropped.
const relative = path.relative(folderPath, candidate);
if (relative.length === 0 || relative.startsWith('..') || path.isAbsolute(relative)) {
return undefined;
extension/src/lm/appHostLifecycleTools.ts:388
- A failure of the initial
aspire psprobe reaches this outer catch before any editor-owned session or controller has been identified, but the result reportscontroller: 'editor'. That makes the tool's structured response inaccurate; the controller contract already definesunknownfor probe failures (and stop uses it correctly). Returnunknownhere and update the existing probe-failure test accordingly.
This issue also appears in the following locations of the same file:
- line 534
- line 558
- line 866
catch (error) {
return this.createErrorResult(aspireAppHostStartToolName, error, preflight.target.relativePath, 'editor', requestedMode, undefined);
extension/src/debugger/AspireDebugConfigurationProvider.ts:146
- The PR description says this intentionally does not add a lifecycle lock or debugger reservation protocol around F5, but this code reserves every external
runand can cancel F5 when a lifecycle-owned launch wins the race. The new tests also explicitly enforce that behavior. Update the description if this user-visible F5 arbitration is intended, or remove the reservation path to match the stated scope.
if (!launchedByExtension && getAspireDebugConfigurationCommand(aspireConfig) === 'run') {
const claimedPath = telemetryTarget?.path ?? (typeof config.program === 'string' ? config.program : undefined);
const reservationId = existingExternalReservationId ?? (claimedPath
? this._launchReservation.tryReserveExternalLaunch(claimedPath)
: false);
- Files reviewed: 34/34 changed files
- Comments generated: 1
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
There was a problem hiding this comment.
Review details
Suppressed comments (3)
extension/src/lm/appHostLifecycleTools.ts:868
- This containment check rejects valid in-workspace paths whose first segment merely begins with
..(for example,/workspace/..hidden/AppHost.csprojproduces..hidden/AppHost.csproj). Such AppHosts are returned by discovery but silently removed from the tool registry, so the documented “discovered AppHosts” contract is incomplete. Only reject the exact parent segment or a..${path.sep}prefix.
const relative = path.relative(folderPath, candidate);
if (relative.length === 0 || relative.startsWith('..') || path.isAbsolute(relative)) {
return undefined;
extension/src/lm/appHostLifecycleTools.ts:387
- An
aspire psfailure from eitherisRunningOutsideEditorcall reaches this catch before the extension has identified or acted on an editor-owned session, yet the result reportscontroller: 'editor'. This contradicts the result contract above, whereunknownis specifically reserved for probe failures, and can make an agent treat an externally controlled or indeterminate AppHost as editor-owned. Classify probe/pre-launch failures asunknown(while keeping launch failures classified aseditor) and update the corresponding assertion.
catch (error) {
return this.createErrorResult(aspireAppHostStartToolName, error, preflight.target.relativePath, 'editor', requestedMode, undefined);
extension/src/lm/appHostLifecycleTools.ts:547
- The discovery service returns candidates of every status, but this loop registers all of them. As a result, selectors for non-buildable candidates are accepted and can reach
startDebugging, contrary to the PR’s explicit guarantee that these tools only accept buildable AppHosts (the existing discovery helpers define buildable asstatus === 'buildable'). Filter candidates before creating tool targets so rejected candidates are not listed inknownAppHostseither.
for (const candidate of candidates) {
const relativePath = toContainedPosixRelativePath(folder.uri.fsPath, candidate.path);
- Files reviewed: 34/34 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Ella Hathaway (ellahathaway)
left a comment
There was a problem hiding this comment.
I reviewed the AppHost lifecycle tools end to end, including target resolution and confirmation, workspace trust, editor/external ownership, F5 reservations, lifecycle locking, debug-session tracking, cancellation/disposal, process termination, localization, and unit/E2E coverage.
I found two concrete issues: one deterministic CI configuration failure and one lifecycle-lock race reintroduced after an earlier fix. I did not find separate nonblocking architectural concerns; the central lifecycle ownership and supporting boundaries are otherwise coherent.
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Restore multi-key queueing when directory mutations merge active AppHost identities, and keep the lifecycle E2E shards blocking. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 27642b09-2b7b-40b4-bdb8-f3556b1862f9
There was a problem hiding this comment.
Review details
Suppressed comments (2)
extension/src/utils/appHostIdentity.ts:13
apphost.cscan be an independently discovered, buildable single-file AppHost, but this identity model aliases it to the sole.csprojin the directory. When both are valid candidates, starting one is incorrectly deduplicated against the other, and stopping by one selector can terminate the other's editor session.AppHostDiscovery.findCandidateForEditorFilealready treats an exactapphost.cscandidate as distinct; this comparison needs equivalent candidate-aware handling rather than inferring identity solely from directory shape.
extension/src/lm/appHostLifecycleTools.ts:546discover()returns all CLI candidates, includingpossibly-unbuildableentries; the rest of the extension explicitly filters these before offering launch targets. Adding every candidate here lets the tool accept and try to start an AppHost that the PR contract says must be buildable. Filter oncandidate.status === 'buildable'before constructing the target.
for (const candidate of candidates) {
- Files reviewed: 34/34 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 27642b09-2b7b-40b4-bdb8-f3556b1862f9
There was a problem hiding this comment.
Review details
Suppressed comments (2)
extension/src/lm/appHostLifecycleTools.ts:546
- The discovery service also returns non-buildable candidates, but this loop exposes every candidate as a valid lifecycle target. That contradicts the tool contract and lets an agent attempt to launch an AppHost that discovery explicitly marked unbuildable (and advertises it through
knownAppHosts). Filter candidates tostatus === 'buildable'before adding them.
for (const candidate of candidates) {
extension/src/lm/appHostLifecycleTools.ts:867
- This containment check also rejects valid in-workspace paths whose first segment merely starts with two dots, such as
..apps/AppHost.csproj. Such a buildable discovered AppHost is silently omitted from both resolution andknownAppHosts; only an exact..parent segment should be rejected.
if (relative.length === 0 || relative.startsWith('..') || path.isAbsolute(relative)) {
- Files reviewed: 35/35 changed files
- Comments generated: 1
- Review effort level: Balanced
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 27642b09-2b7b-40b4-bdb8-f3556b1862f9
There was a problem hiding this comment.
Review details
Suppressed comments (4)
extension/src/lm/appHostLifecycleTools.ts:546
AppHostDiscoveryService.discover()returns candidates in every status, but this loop exposes all of them as valid tool targets. A candidate reported as unbuildable can therefore appear inknownAppHostsand be passed tolaunchFromLifecycleOwner, contradicting the buildable-only contract. Filter candidates bystatus === 'buildable'before creating targets.
for (const candidate of candidates) {
extension/src/lm/appHostLifecycleTools.ts:867
- This containment check rejects valid children whose first path segment merely starts with
..(for example,<workspace>/..generated/AppHost.csproj). Such a discovered AppHost is omitted and even the selector returned by discovery cannot be used. Only the actual parent segment..followed by the platform separator should count as escaping the folder.
if (relative.length === 0 || relative.startsWith('..') || path.isAbsolute(relative)) {
extension/src/lm/appHostLifecycleTools.ts:560
- Workspace folder names are not guaranteed to be unique (two roots commonly share the same basename). If both contain the same relative AppHost path, this produces identical
displayPathvalues; resolution then returnsambiguousAppHostwith two identicalknownAppHosts, leaving no selector that can address either target. The qualifier scheme needs a stable unique fallback for duplicate folder names, with the manifest/README contract updated accordingly.
const displayPath = workspaceFolders.length > 1
? `${folder.name}/${relativePath}`
: relativePath;
extension/src/lm/appHostLifecycleTools.ts:387
- This catch also handles failure of the pre-lock
getRunningAppHostsprobe, but reportscontroller: 'editor'even though no editor operation occurred and ownership could not be determined. That contradicts the result contract above, which definesunknownspecifically for a failed probe, and can mislead an agent about who controls the AppHost. Distinguish probe/lock failures from failures after the editor launch path has been selected.
catch (error) {
return this.createErrorResult(aspireAppHostStartToolName, error, preflight.target.relativePath, 'editor', requestedMode, undefined);
- Files reviewed: 35/35 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 27642b09-2b7b-40b4-bdb8-f3556b1862f9
There was a problem hiding this comment.
Review details
Suppressed comments (3)
extension/src/lm/appHostLifecycleTools.ts:547
- The lifecycle registry currently accepts every
aspire lscandidate, includingpossibly-unbuildableentries. That lets the tools launch targets the PR contract says must be rejected and that the existing AppHost view excludes. Filter candidates tostatus === 'buildable'before exposing selectors.
for (const candidate of candidates) {
extension/src/lm/appHostLifecycleTools.ts:387
- This catch also handles failures from both
aspire psprobes, but reportscontroller: 'editor'even though no editor session was established and the controller could not be determined. That contradicts the result contract'sunknowncontroller and can mislead an agent into treating an external-process probe failure as editor ownership. Catch probe failures separately or carry the established controller into error mapping.
catch (error) {
return this.createErrorResult(aspireAppHostStartToolName, error, preflight.target.relativePath, 'editor', requestedMode, undefined);
extension/src/lm/appHostLifecycleTools.ts:867
- This containment check rejects valid in-workspace paths whose first segment merely begins with
..(for example..tools/AppHost.csproj). Such a buildable discovered AppHost is incorrectly dropped as outside the workspace. Match the parent segment itself, including its separator, instead of any..prefix.
if (relative.length === 0 || relative.startsWith('..') || path.isAbsolute(relative)) {
- Files reviewed: 36/36 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 27642b09-2b7b-40b4-bdb8-f3556b1862f9
There was a problem hiding this comment.
Review details
Suppressed comments (2)
extension/src/lm/appHostLifecycleTools.ts:560
WorkspaceFolder.nameis user-configurable and is not guaranteed to be unique. If two roots use the same name and contain the same relative AppHost path, both targets receive the samedisplayPath; resolution then returnsambiguousAppHostwith duplicateknownAppHosts, so neither AppHost has any selector the caller can use. Generate deterministic unique folder qualifiers when names collide.
const displayPath = workspaceFolders.length > 1
? `${folder.name}/${relativePath}`
: relativePath;
extension/src/lm/appHostLifecycleTools.ts:546
- Discovery returns candidates in every status, but this loop exposes all of them as valid tool targets. The Aspire view filters to
status === 'buildable', so an agent can currently select and launch a non-buildable candidate even though the tool contract says it accepts only buildable AppHosts. Filter candidates before adding them totargets.
This issue also appears on line 558 of the same file.
for (const candidate of candidates) {
- Files reviewed: 36/36 changed files
- Comments generated: 1
- Review effort level: Balanced
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 27642b09-2b7b-40b4-bdb8-f3556b1862f9
There was a problem hiding this comment.
Review details
Suppressed comments (4)
extension/src/lm/appHostLifecycleTools.ts:111
- In a multi-root workspace, all outcomes return
relativePath, which omits the folder qualifier required by the tool input. For example, startingsecond/AppHost/AppHost.csprojreportsappHostPath: "AppHost/AppHost.csproj"; passing that result to the stop tool is then rejected as ambiguous. Return the stabledisplayPathselector in tool results so outputs can be used as inputs.
/** Path relative to the containing workspace folder, or empty when the input could not be resolved. */
appHostPath: string;
extension/src/lm/appHostLifecycleTools.ts:547
- Discovery returns candidates such as
possibly-unbuildable, but this loop registers every status as an executable target. That contradicts the tool contract that only buildable AppHosts are accepted and can make the agent launch a candidate the existing workspace selection logic deliberately excludes. Filter tostatus === 'buildable'before adding the target.
for (const candidate of candidates) {
const relativePath = toContainedPosixRelativePath(folder.uri.fsPath, candidate.path);
extension/src/lm/appHostLifecycleTools.ts:869
- This containment test rejects valid in-workspace names whose first segment merely begins with
..(for example..tools/AppHost.csproj).path.relative()only denotes a parent traversal when the result is exactly..or starts with..plus the platform separator.
const relative = path.relative(folderPath, candidate);
if (relative.length === 0 || relative.startsWith('..') || path.isAbsolute(relative)) {
return undefined;
extension/loc/xlf/aspire-vscode.xlf:21
- This is a generated localization export. The extension localization guidance explicitly says not to edit or commit
*.xlfoutput because source strings belong inpackage.nls.json/src/loc/strings.tsand translations are handled by the localization workflow. Revert all additions in this generated file.
<trans-unit id="aspire-vscode.strings.appHostLifecycleBusy">
<source xml:lang="en">Another start or stop operation for this Aspire AppHost is still in progress. Wait for it to finish and try again.</source>
</trans-unit>
- Files reviewed: 36/36 changed files
- Comments generated: 1
- Review effort level: Balanced
|
Retrying the failed CI jobs for this pull request from the CI run attempt. The rerun is being tracked in the rerun attempt. |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 27642b09-2b7b-40b4-bdb8-f3556b1862f9
There was a problem hiding this comment.
Review details
Suppressed comments (3)
extension/src/lm/appHostLifecycleTools.ts:546
discover()returns every Aspire candidate, including entries whose status is notbuildable; the AppHost view explicitly filters these before exposing lifecycle actions. This loop currently adds those failed candidates toknownAppHostsand allows the tool to pass them tostartDebugging, contradicting the PR's buildable-only contract. Filter oncandidate.status === 'buildable'before creating a target.
for (const candidate of candidates) {
extension/src/debugger/AspireDebugConfigurationProvider.ts:156
- Directory scoping is only enabled for the workspace root. A supported
launch.jsontarget that names an AppHost subdirectory remains a directory afterresolveDebugTarget, but is reserved here as an exact path; that reservation does not overlap the discovered project file, and the parent session also cannot match it until the child session is tracked. During that startup window the agent tool can launch a duplicate AppHost. Resolve the subdirectory to its concrete candidate or reserve any unresolved directory target with directory scope.
const isDirectoryScope = telemetryTarget === undefined && isWorkspaceFolderLaunch;
extension/src/lm/appHostLifecycleTools.ts:867
- This containment test rejects valid in-workspace names beginning with
.., such as..apps/AppHost.csproj, because it treats everyrelative.startsWith('..')value as traversal. Such a buildable discovered AppHost is silently omitted fromknownAppHostsand can never be started or stopped. Only reject the..segment itself or a..${path.sep}prefix.
if (relative.length === 0 || relative.startsWith('..') || path.isAbsolute(relative)) {
- Files reviewed: 36/36 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Description
Adds two Language Model tools so agents can use VS Code's AppHost lifecycle instead of starting a separate CLI process:
aspire_apphost_start#aspireStartAppHostrunordebugmode throughAppHostLaunchServiceaspire_apphost_stop#aspireStopAppHostaspire stop --apphostThe tools only accept buildable AppHosts returned by the extension's existing discovery service. Unknown selectors return the available
knownAppHosts, multi-root workspaces get stable folder qualifiers, and confirmation text is built from the resolved target rather than echoed model input.Before starting, the extension checks its tracked Aspire sessions and a one-shot
aspire psresult. Start reports an AppHost started outside this editor instead of launching a duplicate. Stop coordinates a matching Aspire debug session when this editor owns one; otherwise it delegates toaspire stop --apphostfor the same discovered AppHost. Lifecycle operations are serialized per AppHost across tool and editor launches.Both tools require a trusted workspace and user confirmation.
Fixes #18324
Evidence
Start confirmation
Stop confirmation
Checklist
The model-provided selector is never used as a filesystem target. It is only compared with selectors generated from discovered AppHosts. Stop coordinates a matching tracked debug session when present; otherwise, for a matching running AppHost returned by
aspire ps, it delegates toaspire stop --apphostwith the resolved discovered path.