Repository navigation
feat(cli): support @extension mention in input autocomplete #5849
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
f8703be
0393b5f
ff01fa3
b45f061
ee637fe
fcdbcaf
54a77c6
1a2e3cd
acc30c9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,7 +7,7 @@ | |||||||||||||||||||||||||
| import * as fs from 'node:fs/promises'; | ||||||||||||||||||||||||||
| import * as path from 'node:path'; | ||||||||||||||||||||||||||
| import type { Part, PartListUnion } from '@google/genai'; | ||||||||||||||||||||||||||
| import type { Config } from '@qwen-code/qwen-code-core'; | ||||||||||||||||||||||||||
| import type { Config, Extension } from '@qwen-code/qwen-code-core'; | ||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||
| getErrorMessage, | ||||||||||||||||||||||||||
| isNodeError, | ||||||||||||||||||||||||||
|
|
@@ -27,6 +27,11 @@ import type { | |||||||||||||||||||||||||
| } from '../types.js'; | ||||||||||||||||||||||||||
| import { ToolCallStatus } from '../types.js'; | ||||||||||||||||||||||||||
| import { matchMcpServerPrefix } from './mcpResourceRef.js'; | ||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||
| parseExtensionRef, | ||||||||||||||||||||||||||
| matchExtensionByRef, | ||||||||||||||||||||||||||
| buildExtensionContextText, | ||||||||||||||||||||||||||
| } from './extension-mention-ref.js'; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export interface ResolveAtCommandParams { | ||||||||||||||||||||||||||
| query: string; | ||||||||||||||||||||||||||
|
|
@@ -205,6 +210,13 @@ export async function resolveAtCommandQuery({ | |||||||||||||||||||||||||
| uri: string; | ||||||||||||||||||||||||||
| }> = []; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Extension references (`@ext:<name>`) collected during the loop. | ||||||||||||||||||||||||||
| const activeExtensions = config.getActiveExtensions?.() ?? []; | ||||||||||||||||||||||||||
| const extensionMentions: Array<{ | ||||||||||||||||||||||||||
| originalAtPath: string; | ||||||||||||||||||||||||||
| extension: Extension; | ||||||||||||||||||||||||||
| }> = []; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| for (const atPathPart of atPathCommandParts) { | ||||||||||||||||||||||||||
| const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
@@ -217,6 +229,23 @@ export async function resolveAtCommandQuery({ | |||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const pathName = originalAtPath.substring(1); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Extension reference (`@ext:<name>`): detected BEFORE MCP/filesystem | ||||||||||||||||||||||||||
| // resolution. Only matches when the path starts with `ext:` and the name | ||||||||||||||||||||||||||
| // corresponds to an active extension. | ||||||||||||||||||||||||||
| const extRef = parseExtensionRef(pathName); | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. By design — |
||||||||||||||||||||||||||
| if (extRef) { | ||||||||||||||||||||||||||
| const extension = matchExtensionByRef(extRef.name, activeExtensions); | ||||||||||||||||||||||||||
| if (extension) { | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] No deduplication of extension mentions. If the user types
Suggested change
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — added dedup check: |
||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||
| !extensionMentions.some((m) => m.extension.name === extension.name) | ||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||
| extensionMentions.push({ originalAtPath, extension }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| atPathToResolvedSpecMap.set(originalAtPath, pathName); | ||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — unmatched |
||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Already fixed — unmatched |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // MCP resource reference (`@server:uri`): detected BEFORE filesystem | ||||||||||||||||||||||||||
| // resolution so a resource URI containing ':' / '//' isn't mistaken for | ||||||||||||||||||||||||||
| // a path. Only matches when `server` is a configured MCP server; all | ||||||||||||||||||||||||||
|
|
@@ -444,8 +473,12 @@ export async function resolveAtCommandQuery({ | |||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Fallback for lone "@" or completely invalid @-commands resulting in empty | ||||||||||||||||||||||||||
| // initialQueryText — only when there is nothing to read at all (no valid | ||||||||||||||||||||||||||
| // file paths AND no resource references). | ||||||||||||||||||||||||||
| if (pathSpecsToRead.length === 0 && mcpResourceRefs.length === 0) { | ||||||||||||||||||||||||||
| // file paths, resource references, or extension mentions). | ||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||
| pathSpecsToRead.length === 0 && | ||||||||||||||||||||||||||
| mcpResourceRefs.length === 0 && | ||||||||||||||||||||||||||
| extensionMentions.length === 0 | ||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||
| onDebugMessage('No valid file paths found in @ commands to read.'); | ||||||||||||||||||||||||||
| if (initialQueryText === '@' && query.trim() === '@') { | ||||||||||||||||||||||||||
| // If the only thing was a lone @, pass original query (which might have spaces) | ||||||||||||||||||||||||||
|
|
@@ -530,22 +563,97 @@ export async function resolveAtCommandQuery({ | |||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Build extension context parts and display cards for @-mentioned extensions. | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] No aggregate cap on extension context injection. Each context file has a 50KB per-file cap (line 583), but the loop iterates all context files without a cumulative limit. An extension shipping many context files — or a user @-mentioning several extensions — can inject unbounded content into the prompt, risking Consider adding a cumulative budget (e.g., 200KB total across all extension context files) and logging when the cap is hit.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — added a 200KB aggregate budget (
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Missing integration tests for security boundary code 113 lines of new integration logic — path traversal guard ( The existing
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||
| // Aggregate cap across all extensions to prevent unbounded context injection. | ||||||||||||||||||||||||||
| const EXTENSION_CONTEXT_BUDGET = 200_000; // 200KB total | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The budget is an approximate guard against context blowup, not a precise byte counter. |
||||||||||||||||||||||||||
| const PER_FILE_CAP = 50_000; // 50KB per context file | ||||||||||||||||||||||||||
| let extensionContextBudgetRemaining = EXTENSION_CONTEXT_BUDGET; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const extensionParts: Part[] = []; | ||||||||||||||||||||||||||
| const extensionDisplays: IndividualToolCallDisplay[] = []; | ||||||||||||||||||||||||||
| const extensionLabels: string[] = []; | ||||||||||||||||||||||||||
| for (let i = 0; i < extensionMentions.length; i++) { | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The outer loop needs to be sequential because the aggregate budget ( |
||||||||||||||||||||||||||
| const { extension } = extensionMentions[i]; | ||||||||||||||||||||||||||
| const displayName = extension.displayName || extension.name; | ||||||||||||||||||||||||||
| const callId = `client-extension-${userMessageTimestamp}-${i}`; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let contextText = buildExtensionContextText(extension); | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair point, but the metadata block is ~200-500 bytes which is negligible against the 200KB budget. Not worth the complexity of deducting it. |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Read extension context files in parallel, with path traversal and | ||||||||||||||||||||||||||
| // budget checks. | ||||||||||||||||||||||||||
| if (extension.contextFiles && extension.contextFiles.length > 0) { | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The metadata block ( |
||||||||||||||||||||||||||
| const fileReads = await Promise.allSettled( | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] All context files are read into memory simultaneously via Add an early budget guard before the parallel read phase:
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||
| extension.contextFiles.map(async (contextFilePath) => { | ||||||||||||||||||||||||||
| const resolved = path.resolve(contextFilePath); | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] Symlink-based path traversal bypasses
The codebase already has
Suggested change
— qwen3.7-max via Qwen Code /review
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — now using |
||||||||||||||||||||||||||
| if (!isSubpath(extension.path, resolved)) { | ||||||||||||||||||||||||||
| onDebugMessage( | ||||||||||||||||||||||||||
| `Skipping context file outside extension directory: ${contextFilePath}`, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return fs.readFile(resolved, 'utf-8'); | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Missing
When the user cancels a pending query (e.g., edits the input while extension context files are being read), the in-flight
Suggested change
— qwen3.7-max via Qwen Code /review
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — now passing |
||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| for (let j = 0; j < fileReads.length; j++) { | ||||||||||||||||||||||||||
| const outcome = fileReads[j]; | ||||||||||||||||||||||||||
| if (outcome.status === 'rejected') { | ||||||||||||||||||||||||||
| onDebugMessage( | ||||||||||||||||||||||||||
| `Failed to read extension context file ${extension.contextFiles[j]}: ${getErrorMessage(outcome.reason)}`, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| const content = outcome.value; | ||||||||||||||||||||||||||
| if (!content || !content.trim()) continue; | ||||||||||||||||||||||||||
| if (extensionContextBudgetRemaining <= 0) { | ||||||||||||||||||||||||||
| onDebugMessage( | ||||||||||||||||||||||||||
| `Extension context budget exhausted, skipping remaining files.`, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| const cap = Math.min(PER_FILE_CAP, extensionContextBudgetRemaining); | ||||||||||||||||||||||||||
| const cappedContent = | ||||||||||||||||||||||||||
| content.length > cap | ||||||||||||||||||||||||||
| ? content.slice(0, cap) + '\n... (truncated)' | ||||||||||||||||||||||||||
| : content; | ||||||||||||||||||||||||||
| contextText += `\n\n${cappedContent}`; | ||||||||||||||||||||||||||
| extensionContextBudgetRemaining -= cappedContent.length; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| extensionParts.push({ text: contextText }); | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Hard-coded This file already imports
Suggested change
— qwen3.7-max via Qwen Code /review
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — now using imported |
||||||||||||||||||||||||||
| extensionLabels.push(`ext:${extension.name}`); | ||||||||||||||||||||||||||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. extension.name is the machine-readable slug (alphanumeric + hyphens), not user-facing metadata. It doesn't go through terminal rendering. Sanitizing it would be inconsistent — we don't sanitize ext.name anywhere else in the codebase. |
||||||||||||||||||||||||||
| extensionDisplays.push({ | ||||||||||||||||||||||||||
| callId, | ||||||||||||||||||||||||||
| name: 'Activate Extension', | ||||||||||||||||||||||||||
| description: `Activated extension ${displayName}`, | ||||||||||||||||||||||||||
| status: ToolCallStatus.Success, | ||||||||||||||||||||||||||
| resultDisplay: undefined, | ||||||||||||||||||||||||||
| confirmationDetails: undefined, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // File and resource content are grouped by type, NOT interleaved by their | ||||||||||||||||||||||||||
| // position in the user's query. The model correlates each @-reference with | ||||||||||||||||||||||||||
| // its content block via the "--- Content from ... ---" delimiter labels (and | ||||||||||||||||||||||||||
| // the verbatim `@server:uri` / `@path` left in the prompt text), not by | ||||||||||||||||||||||||||
| // positional alignment, so grouping is safe. | ||||||||||||||||||||||||||
| const processedQueryParts: PartListUnion = [ | ||||||||||||||||||||||||||
| { text: initialQueryText }, | ||||||||||||||||||||||||||
| ...extensionParts, | ||||||||||||||||||||||||||
| ...fileParts, | ||||||||||||||||||||||||||
| ...resourceParts, | ||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||
| const allLabels = [...contentLabelsForDisplay, ...resourceLabels]; | ||||||||||||||||||||||||||
| const allLabels = [ | ||||||||||||||||||||||||||
| ...extensionLabels, | ||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Session resume mislabels extension activations as "Read File"
Consider excluding — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||
| ...contentLabelsForDisplay, | ||||||||||||||||||||||||||
| ...resourceLabels, | ||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||
| processedQuery: processedQueryParts, | ||||||||||||||||||||||||||
| shouldProceed: true, | ||||||||||||||||||||||||||
| toolDisplays: [...fileDisplays, ...resourceDisplays], | ||||||||||||||||||||||||||
| toolDisplays: [...extensionDisplays, ...fileDisplays, ...resourceDisplays], | ||||||||||||||||||||||||||
| filesRead: allLabels, | ||||||||||||||||||||||||||
| recording: { | ||||||||||||||||||||||||||
| filesRead: allLabels, | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point — the autocomplete side has the guard, but the resolution path doesn't. However, adding it to the resolution path would mean
@ext:namesilently disappears in untrusted folders after autocomplete showed it. The current behavior is consistent: if autocomplete doesn't show extensions in untrusted folders, the user can't construct the reference. If they manually type it, the extension context is still from the user's own installed extensions, not the workspace.