diff --git a/eslint.config.js b/eslint.config.js index 509648a12c1..9fb6db94c50 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1824,6 +1824,7 @@ export default tseslint.config( // terminalContrib is one extra folder deep 'vs/workbench/contrib/terminalContrib/*/~', 'vscode-notebook-renderer', // Type only import + '@vscode/tree-sitter-wasm', // type import { 'when': 'hasBrowser', 'pattern': '@xterm/xterm' diff --git a/package-lock.json b/package-lock.json index e322ffc91d9..879bca4ff26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@vscode/spdlog": "^0.15.2", "@vscode/sqlite3": "5.1.8-vscode", "@vscode/sudo-prompt": "9.3.1", - "@vscode/tree-sitter-wasm": "^0.1.4", + "@vscode/tree-sitter-wasm": "^0.2.0", "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", @@ -3319,9 +3319,9 @@ } }, "node_modules/@vscode/tree-sitter-wasm": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.4.tgz", - "integrity": "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.2.0.tgz", + "integrity": "sha512-abvLfKwmriqgdS4WrIzFK7mzdPUVqIIW1UWarp2lA8lpOZ1EDPL1snRBKe7g+5R5ri173mNJEuPLnG/NlpMp4w==", "license": "MIT" }, "node_modules/@vscode/v8-heap-parser": { diff --git a/package.json b/package.json index 827154f201b..e91ff636b1a 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@vscode/spdlog": "^0.15.2", "@vscode/sqlite3": "5.1.8-vscode", "@vscode/sudo-prompt": "9.3.1", - "@vscode/tree-sitter-wasm": "^0.1.4", + "@vscode/tree-sitter-wasm": "^0.2.0", "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", diff --git a/remote/package-lock.json b/remote/package-lock.json index 238a0284ce0..09d6c70275c 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -16,7 +16,7 @@ "@vscode/proxy-agent": "^0.35.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.2", - "@vscode/tree-sitter-wasm": "^0.1.4", + "@vscode/tree-sitter-wasm": "^0.2.0", "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", @@ -185,9 +185,9 @@ } }, "node_modules/@vscode/tree-sitter-wasm": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.4.tgz", - "integrity": "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.2.0.tgz", + "integrity": "sha512-abvLfKwmriqgdS4WrIzFK7mzdPUVqIIW1UWarp2lA8lpOZ1EDPL1snRBKe7g+5R5ri173mNJEuPLnG/NlpMp4w==", "license": "MIT" }, "node_modules/@vscode/vscode-languagedetection": { diff --git a/remote/package.json b/remote/package.json index 268bdf55729..41979903e57 100644 --- a/remote/package.json +++ b/remote/package.json @@ -11,7 +11,7 @@ "@vscode/proxy-agent": "^0.35.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.2", - "@vscode/tree-sitter-wasm": "^0.1.4", + "@vscode/tree-sitter-wasm": "^0.2.0", "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index fab2167c05e..b3a80a4e8db 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -11,7 +11,7 @@ "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@vscode/iconv-lite-umd": "0.7.1", - "@vscode/tree-sitter-wasm": "^0.1.4", + "@vscode/tree-sitter-wasm": "^0.2.0", "@vscode/vscode-languagedetection": "1.0.21", "@xterm/addon-clipboard": "^0.2.0-beta.114", "@xterm/addon-image": "^0.9.0-beta.131", @@ -78,9 +78,9 @@ "license": "MIT" }, "node_modules/@vscode/tree-sitter-wasm": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.4.tgz", - "integrity": "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.2.0.tgz", + "integrity": "sha512-abvLfKwmriqgdS4WrIzFK7mzdPUVqIIW1UWarp2lA8lpOZ1EDPL1snRBKe7g+5R5ri173mNJEuPLnG/NlpMp4w==", "license": "MIT" }, "node_modules/@vscode/vscode-languagedetection": { diff --git a/remote/web/package.json b/remote/web/package.json index 8494a8b5090..46a1fefe151 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -6,7 +6,7 @@ "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@vscode/iconv-lite-umd": "0.7.1", - "@vscode/tree-sitter-wasm": "^0.1.4", + "@vscode/tree-sitter-wasm": "^0.2.0", "@vscode/vscode-languagedetection": "1.0.21", "@xterm/addon-clipboard": "^0.2.0-beta.114", "@xterm/addon-image": "^0.9.0-beta.131", diff --git a/src/vs/editor/common/model/tokens/treeSitter/treeSitterSyntaxTokenBackend.ts b/src/vs/editor/common/model/tokens/treeSitter/treeSitterSyntaxTokenBackend.ts index 5d61d664043..de7890799df 100644 --- a/src/vs/editor/common/model/tokens/treeSitter/treeSitterSyntaxTokenBackend.ts +++ b/src/vs/editor/common/model/tokens/treeSitter/treeSitterSyntaxTokenBackend.ts @@ -54,7 +54,7 @@ export class TreeSitterSyntaxTokenBackend extends AbstractSyntaxTokenBackend { } const currentLanguage = this._languageIdObs.read(reader); - const treeSitterLang = this._treeSitterLibraryService.getLanguage(currentLanguage, reader); + const treeSitterLang = this._treeSitterLibraryService.getLanguage(currentLanguage, false, reader); if (!treeSitterLang) { return undefined; } diff --git a/src/vs/editor/common/services/treeSitter/treeSitterLibraryService.ts b/src/vs/editor/common/services/treeSitter/treeSitterLibraryService.ts index b24a348c2d4..3c907113854 100644 --- a/src/vs/editor/common/services/treeSitter/treeSitterLibraryService.ts +++ b/src/vs/editor/common/services/treeSitter/treeSitterLibraryService.ts @@ -12,20 +12,46 @@ export const ITreeSitterLibraryService = createDecorator; - supportsLanguage(languageId: string, reader: IReader | undefined): boolean; - getLanguage(languageId: string, reader: IReader | undefined): Language | undefined; /** - * Return value of null indicates that there are no injection queries for this language. - * @param languageId - * @param reader + * Checks whether a language is supported and available based setting enablement. + * @param languageId The language identifier to check. + * @param reader Optional observable reader. + */ + supportsLanguage(languageId: string, reader: IReader | undefined): boolean; + + /** + * Gets the tree sitter Language object synchronously. + * @param languageId The language identifier to retrieve. + * @param ignoreSupportsCheck Whether to ignore the supportsLanguage check. + * @param reader Optional observable reader. + */ + getLanguage(languageId: string, ignoreSupportsCheck: boolean, reader: IReader | undefined): Language | undefined; + + /** + * Gets the injection queries for a language. A return value of `null` + * indicates that there are no highlights queries for this language. + * @param languageId The language identifier to retrieve queries for. + * @param reader Optional observable reader. */ getInjectionQueries(languageId: string, reader: IReader | undefined): Query | null | undefined; + /** - * Return value of null indicates that there are no highlights queries for this language. - * @param languageId - * @param reader + * Gets the highlighting queries for a language. A return value of `null` + * indicates that there are no highlights queries for this language. + * @param languageId The language identifier to retrieve queries for. + * @param reader Optional observable reader. */ getHighlightingQueries(languageId: string, reader: IReader | undefined): Query | null | undefined; + + /** + * Creates a one-off custom query for a language. + * @param language The Language to create the query for. + * @param querySource The query source string to compile. + */ + createQuery(language: Language, querySource: string): Promise; } diff --git a/src/vs/editor/standalone/browser/standaloneTreeSitterLibraryService.ts b/src/vs/editor/standalone/browser/standaloneTreeSitterLibraryService.ts index f5e4f3f95f4..f724e9019ee 100644 --- a/src/vs/editor/standalone/browser/standaloneTreeSitterLibraryService.ts +++ b/src/vs/editor/standalone/browser/standaloneTreeSitterLibraryService.ts @@ -11,30 +11,26 @@ export class StandaloneTreeSitterLibraryService implements ITreeSitterLibrarySer readonly _serviceBrand: undefined; getParserClass(): Promise { - throw new Error('getParserClass is not implemented in StandaloneTreeSitterLibraryService'); + throw new Error('not implemented in StandaloneTreeSitterLibraryService'); } supportsLanguage(languageId: string, reader: IReader | undefined): boolean { return false; } - getLanguage(languageId: string, reader: IReader | undefined): Language | undefined { + getLanguage(languageId: string, ignoreSupportsCheck: boolean, reader: IReader | undefined): Language | undefined { return undefined; } - /** - * Return value of null indicates that there are no injection queries for this language. - * @param languageId - * @param reader - */ + getInjectionQueries(languageId: string, reader: IReader | undefined): Query | null | undefined { return null; } - /** - * Return value of null indicates that there are no highlights queries for this language. - * @param languageId - * @param reader - */ + getHighlightingQueries(languageId: string, reader: IReader | undefined): Query | null | undefined { return null; } + + async createQuery(language: Language, querySource: string): Promise { + throw new Error('not implemented in StandaloneTreeSitterLibraryService'); + } } diff --git a/src/vs/editor/test/common/services/testTreeSitterLibraryService.ts b/src/vs/editor/test/common/services/testTreeSitterLibraryService.ts index e07e75740a9..b724892f883 100644 --- a/src/vs/editor/test/common/services/testTreeSitterLibraryService.ts +++ b/src/vs/editor/test/common/services/testTreeSitterLibraryService.ts @@ -11,14 +11,14 @@ export class TestTreeSitterLibraryService implements ITreeSitterLibraryService { readonly _serviceBrand: undefined; getParserClass(): Promise { - throw new Error('getParserClass is not implemented in TestTreeSitterLibraryService'); + throw new Error('not implemented in TestTreeSitterLibraryService'); } supportsLanguage(languageId: string, reader: IReader | undefined): boolean { return false; } - getLanguage(languageId: string, reader: IReader | undefined): Language | undefined { + getLanguage(languageId: string, ignoreSupportsCheck: boolean, reader: IReader | undefined): Language | undefined { return undefined; } @@ -29,4 +29,8 @@ export class TestTreeSitterLibraryService implements ITreeSitterLibraryService { getHighlightingQueries(languageId: string, reader: IReader | undefined): Query | null | undefined { return null; } + + async createQuery(language: Language, querySource: string): Promise { + throw new Error('not implemented in TestTreeSitterLibraryService'); + } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalHelpers.ts index 36cd3b1ab10..19330fc97ca 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalHelpers.ts @@ -69,7 +69,10 @@ export function generateAutoApproveActions(commandLine: string, subCommands: str // We shouldn't offer configuring rules for commands that are explicitly denied since it // wouldn't get auto approved with a new rule - const canCreateAutoApproval = autoApproveResult.subCommandResults.some(e => e.result !== 'denied') || autoApproveResult.commandLineResult.result === 'denied'; + const canCreateAutoApproval = ( + autoApproveResult.subCommandResults.every(e => e.result !== 'denied') && + autoApproveResult.commandLineResult.result !== 'denied' + ); if (canCreateAutoApproval) { const unapprovedSubCommands = subCommands.filter((_, index) => { return autoApproveResult.subCommandResults[index].result !== 'approved'; @@ -152,9 +155,7 @@ export function generateAutoApproveActions(commandLine: string, subCommands: str if ( firstSubcommandFirstWord !== commandLine && !commandsWithSubcommands.has(commandLine) && - !commandsWithSubSubCommands.has(commandLine) && - autoApproveResult.commandLineResult.result !== 'denied' && - autoApproveResult.subCommandResults.every(e => e.result !== 'denied') + !commandsWithSubSubCommands.has(commandLine) ) { actions.push({ label: localize('autoApprove.exactCommand', 'Always Allow Exact Command Line'), diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/subCommands.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/subCommands.ts deleted file mode 100644 index df98f6c64f8..00000000000 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/subCommands.ts +++ /dev/null @@ -1,75 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type { OperatingSystem } from '../../../../../base/common/platform.js'; -import { isPowerShell } from './runInTerminalHelpers.js'; - -function createNumberRange(start: number, end: number): string[] { - return Array.from({ length: end - start + 1 }, (_, i) => (start + i).toString()); -} - -function sortByStringLengthDesc(arr: string[]): string[] { - return [...arr].sort((a, b) => b.length - a.length); -} - -// Derived from https://github.com/microsoft/vscode/blob/315b0949786b3807f05cb6acd13bf0029690a052/extensions/terminal-suggest/src/tokens.ts#L14-L18 -// Some of these can match the same string, so the order matters. -// -// This isn't perfect, at some point it would be better off moving over to tree sitter for this -// instead of simple string matching. -const shellTypeResetChars = new Map<'sh' | 'zsh' | 'pwsh', string[]>([ - ['sh', sortByStringLengthDesc([ - // Redirection docs (bash) https://www.gnu.org/software/bash/manual/html_node/Redirections.html - ...createNumberRange(1, 9).concat('').map(n => `${n}<<<`), // Here strings - ...createNumberRange(1, 9).concat('').flatMap(n => createNumberRange(1, 9).map(m => `${n}>&${m}`)), // Redirect stream to stream - ...createNumberRange(1, 9).concat('').map(n => `${n}<>`), // Open file descriptor for reading and writing - ...createNumberRange(1, 9).concat('&', '').map(n => `${n}>>`), - ...createNumberRange(1, 9).concat('&', '').map(n => `${n}>`), - '0<', '||', '&&', '|&', '<<', '&', ';', '{', '>', '<', '|' - ])], - ['zsh', sortByStringLengthDesc([ - // Redirection docs https://zsh.sourceforge.io/Doc/Release/Redirection.html - ...createNumberRange(1, 9).concat('').map(n => `${n}<<<`), // Here strings - ...createNumberRange(1, 9).concat('').flatMap(n => createNumberRange(1, 9).map(m => `${n}>&${m}`)), // Redirect stream to stream - ...createNumberRange(1, 9).concat('').map(n => `${n}<>`), // Open file descriptor for reading and writing - ...createNumberRange(1, 9).concat('&', '').map(n => `${n}>>`), - ...createNumberRange(1, 9).concat('&', '').map(n => `${n}>`), - '<(', '||', '>|', '>!', '&&', '|&', '&', ';', '{', '<(', '<', '|' - ])], - ['pwsh', sortByStringLengthDesc([ - // Redirection docs: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_redirection?view=powershell-7.5 - ...createNumberRange(1, 6).concat('*', '').flatMap(n => createNumberRange(1, 6).map(m => `${n}>&${m}`)), // Stream to stream redirection - ...createNumberRange(1, 6).concat('*', '').map(n => `${n}>>`), - ...createNumberRange(1, 6).concat('*', '').map(n => `${n}>`), - '&&', '<', '|', ';', '!', '&' - ])], -]); - -export function splitCommandLineIntoSubCommands(commandLine: string, envShell: string, envOS: OperatingSystem): string[] { - let shellType: 'sh' | 'zsh' | 'pwsh'; - const envShellWithoutExe = envShell.replace(/\.exe$/, ''); - if (isPowerShell(envShell, envOS)) { - shellType = 'pwsh'; - } else { - switch (envShellWithoutExe) { - case 'zsh': shellType = 'zsh'; break; - default: shellType = 'sh'; break; - } - } - const subCommands = [commandLine]; - const resetChars = shellTypeResetChars.get(shellType); - if (resetChars) { - for (const chars of resetChars) { - for (let i = 0; i < subCommands.length; i++) { - const subCommand = subCommands[i]; - if (subCommand.includes(chars)) { - subCommands.splice(i, 1, ...subCommand.split(chars).map(e => e.trim())); - i--; - } - } - } - } - return subCommands.filter(e => e.length > 0); -} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 72861ea8830..0817703f554 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -43,10 +43,10 @@ import { RichExecuteStrategy } from '../executeStrategy/richExecuteStrategy.js'; import { getOutput } from '../outputHelpers.js'; import { dedupeRules, generateAutoApproveActions, isFish, isPowerShell, isWindowsPowerShell, isZsh } from '../runInTerminalHelpers.js'; import { RunInTerminalToolTelemetry } from '../runInTerminalToolTelemetry.js'; -import { splitCommandLineIntoSubCommands } from '../subCommands.js'; import { ShellIntegrationQuality, ToolTerminalCreator, type IToolTerminal } from '../toolTerminalCreator.js'; import { OutputMonitor } from './monitoring/outputMonitor.js'; import { IPollingResult, OutputMonitorState } from './monitoring/types.js'; +import { TreeSitterCommandParser, TreeSitterCommandParserLanguage } from '../treeSitterCommandParser.js'; // #region Tool data @@ -56,10 +56,9 @@ function createPowerShellModelDescription(shell: string): string { `This tool allows you to execute ${isWinPwsh ? 'Windows PowerShell 5.1' : 'PowerShell'} commands in a persistent terminal session, preserving environment variables, working directory, and other context across multiple commands.`, '', 'Command Execution:', - '- Does NOT support multi-line commands', - `- ${isWinPwsh - ? 'Use semicolons ; to chain commands on one line, NEVER use && even when asked explicitly' - : 'Use && to chain simple commands on one line'}`, + // Even for pwsh 7+ we want to use `;` to chain commands since the tree sitter grammar + // doesn't parse `&&`. See https://github.com/airbus-cert/tree-sitter-powershell/issues/27 + '- Use semicolons ; to chain commands on one line, NEVER use && even when asked explicitly', '- Prefer pipelines | for object-based data flow', '', 'Directory Management:', @@ -259,9 +258,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { private readonly _terminalToolCreator: ToolTerminalCreator; private readonly _commandSimplifier: CommandSimplifier; - protected readonly _profileFetcher: TerminalProfileFetcher; + private readonly _treeSitterCommandParser: TreeSitterCommandParser; private readonly _telemetry: RunInTerminalToolTelemetry; protected readonly _commandLineAutoApprover: CommandLineAutoApprover; + protected readonly _profileFetcher: TerminalProfileFetcher; protected readonly _sessionTerminalAssociations: Map = new Map(); // Immutable window state @@ -293,9 +293,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._terminalToolCreator = _instantiationService.createInstance(ToolTerminalCreator); this._commandSimplifier = _instantiationService.createInstance(CommandSimplifier, this._osBackend); - this._profileFetcher = _instantiationService.createInstance(TerminalProfileFetcher); + this._treeSitterCommandParser = this._instantiationService.createInstance(TreeSitterCommandParser); this._telemetry = _instantiationService.createInstance(RunInTerminalToolTelemetry); this._commandLineAutoApprover = this._register(_instantiationService.createInstance(CommandLineAutoApprover)); + this._profileFetcher = _instantiationService.createInstance(TerminalProfileFetcher); // Clear out warning accepted state if the setting is disabled this._register(Event.runAndSubscribe(this._configurationService.onDidChangeConfiguration, e => { @@ -349,94 +350,108 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // can be reviewed in the terminal channel. It also allows gauging the effective set of // commands that would be auto approved if it were enabled. const actualCommand = toolEditedCommand ?? args.command; - const subCommands = splitCommandLineIntoSubCommands(actualCommand, shell, os); - const subCommandResults = subCommands.map(e => this._commandLineAutoApprover.isCommandAutoApproved(e, shell, os)); - const commandLineResult = this._commandLineAutoApprover.isCommandLineAutoApproved(actualCommand); - const autoApproveReasons: string[] = [ - ...subCommandResults.map(e => e.reason), - commandLineResult.reason, - ]; - let isAutoApproved = false; - let isDenied = false; - let autoApproveReason: 'subCommand' | 'commandLine' | undefined; - let autoApproveDefault: boolean | undefined; + let disclaimer: IMarkdownString | undefined; + let customActions: ToolConfirmationAction[] | undefined; - const deniedSubCommandResult = subCommandResults.find(e => e.result === 'denied'); - if (deniedSubCommandResult) { - this._logService.info('autoApprove: Sub-command DENIED auto approval'); - isDenied = true; - autoApproveDefault = deniedSubCommandResult.rule?.isDefaultRule; - autoApproveReason = 'subCommand'; - } else if (commandLineResult.result === 'denied') { - this._logService.info('autoApprove: Command line DENIED auto approval'); - isDenied = true; - autoApproveDefault = commandLineResult.rule?.isDefaultRule; - autoApproveReason = 'commandLine'; - } else { - if (subCommandResults.every(e => e.result === 'approved')) { - this._logService.info('autoApprove: All sub-commands auto-approved'); - autoApproveReason = 'subCommand'; - isAutoApproved = true; - autoApproveDefault = subCommandResults.every(e => e.rule?.isDefaultRule); - } else { - this._logService.info('autoApprove: All sub-commands NOT auto-approved'); - if (commandLineResult.result === 'approved') { - this._logService.info('autoApprove: Command line auto-approved'); - autoApproveReason = 'commandLine'; - isAutoApproved = true; - autoApproveDefault = commandLineResult.rule?.isDefaultRule; - } else { - this._logService.info('autoApprove: Command line NOT auto-approved'); - } - } - } - - // Log detailed auto approval reasoning - for (const reason of autoApproveReasons) { - this._logService.info(`- ${reason}`); - } - - // Apply auto approval or force it off depending on enablement/opt-in state const isAutoApproveEnabled = this._configurationService.getValue(TerminalChatAgentToolsSettingId.EnableAutoApprove) === true; const isAutoApproveWarningAccepted = this._storageService.getBoolean(TerminalToolConfirmationStorageKeys.TerminalAutoApproveWarningAccepted, StorageScope.APPLICATION, false); const isAutoApproveAllowed = isAutoApproveEnabled && isAutoApproveWarningAccepted; - if (isAutoApproveEnabled) { - autoApproveInfo = this._createAutoApproveInfo( - isAutoApproved, - isDenied, + let isAutoApproved = false; + + let subCommands: string[] | undefined; + const treeSitterLanguage = isPowerShell(shell, os) ? TreeSitterCommandParserLanguage.PowerShell : TreeSitterCommandParserLanguage.Bash; + try { + subCommands = await this._treeSitterCommandParser.extractSubCommands(treeSitterLanguage, actualCommand); + this._logService.info(`RunInTerminalTool: autoApprove: Parsed sub-commands via ${treeSitterLanguage} grammar`, subCommands); + } catch (e) { + console.error(e); + this._logService.info(`RunInTerminalTool: autoApprove: Failed to parse sub-commands via ${treeSitterLanguage} grammar`); + } + + if (subCommands) { + const subCommandResults = subCommands.map(e => this._commandLineAutoApprover.isCommandAutoApproved(e, shell, os)); + const commandLineResult = this._commandLineAutoApprover.isCommandLineAutoApproved(actualCommand); + const autoApproveReasons: string[] = [ + ...subCommandResults.map(e => e.reason), + commandLineResult.reason, + ]; + + let isDenied = false; + let autoApproveReason: 'subCommand' | 'commandLine' | undefined; + let autoApproveDefault: boolean | undefined; + + const deniedSubCommandResult = subCommandResults.find(e => e.result === 'denied'); + if (deniedSubCommandResult) { + this._logService.info('RunInTerminalTool: autoApprove: Sub-command DENIED auto approval'); + isDenied = true; + autoApproveDefault = deniedSubCommandResult.rule?.isDefaultRule; + autoApproveReason = 'subCommand'; + } else if (commandLineResult.result === 'denied') { + this._logService.info('RunInTerminalTool: autoApprove: Command line DENIED auto approval'); + isDenied = true; + autoApproveDefault = commandLineResult.rule?.isDefaultRule; + autoApproveReason = 'commandLine'; + } else { + if (subCommandResults.every(e => e.result === 'approved')) { + this._logService.info('RunInTerminalTool: autoApprove: All sub-commands auto-approved'); + autoApproveReason = 'subCommand'; + isAutoApproved = true; + autoApproveDefault = subCommandResults.every(e => e.rule?.isDefaultRule); + } else { + this._logService.info('RunInTerminalTool: autoApprove: All sub-commands NOT auto-approved'); + if (commandLineResult.result === 'approved') { + this._logService.info('RunInTerminalTool: autoApprove: Command line auto-approved'); + autoApproveReason = 'commandLine'; + isAutoApproved = true; + autoApproveDefault = commandLineResult.rule?.isDefaultRule; + } else { + this._logService.info('RunInTerminalTool: autoApprove: Command line NOT auto-approved'); + } + } + } + + // Log detailed auto approval reasoning + for (const reason of autoApproveReasons) { + this._logService.info(`RunInTerminalTool: autoApprove: - ${reason}`); + } + + // Apply auto approval or force it off depending on enablement/opt-in state + if (isAutoApproveEnabled) { + autoApproveInfo = this._createAutoApproveInfo( + isAutoApproved, + isDenied, + autoApproveReason, + subCommandResults, + commandLineResult, + ); + } else { + isAutoApproved = false; + } + + // Send telemetry about auto approval process + this._telemetry.logPrepare({ + terminalToolSessionId, + subCommands, + autoApproveAllowed: !isAutoApproveEnabled ? 'off' : isAutoApproveWarningAccepted ? 'allowed' : 'needsOptIn', + autoApproveResult: isAutoApproved ? 'approved' : isDenied ? 'denied' : 'manual', autoApproveReason, - subCommandResults, - commandLineResult, - ); - } else { - isAutoApproved = false; - } + autoApproveDefault + }); - // Send telemetry about auto approval process - this._telemetry.logPrepare({ - terminalToolSessionId, - subCommands, - autoApproveAllowed: !isAutoApproveEnabled ? 'off' : isAutoApproveWarningAccepted ? 'allowed' : 'needsOptIn', - autoApproveResult: isAutoApproved ? 'approved' : isDenied ? 'denied' : 'manual', - autoApproveReason, - autoApproveDefault - }); + // Add a disclaimer warning about prompt injection for common commands that return + // content from the web + const subCommandsLowerFirstWordOnly = subCommands.map(command => command.split(' ')[0].toLowerCase()); + if (!isAutoApproved && ( + subCommandsLowerFirstWordOnly.some(command => promptInjectionWarningCommandsLower.includes(command)) || + (isPowerShell(shell, os) && subCommandsLowerFirstWordOnly.some(command => promptInjectionWarningCommandsLowerPwshOnly.includes(command))) + )) { + disclaimer = new MarkdownString(`$(${Codicon.info.id}) ` + localize('runInTerminal.promptInjectionDisclaimer', 'Web content may contain malicious code or attempt prompt injection attacks.'), { supportThemeIcons: true }); + } - // Add a disclaimer warning about prompt injection for common commands that return - // content from the web - let disclaimer: IMarkdownString | undefined; - const subCommandsLowerFirstWordOnly = subCommands.map(command => command.split(' ')[0].toLowerCase()); - if (!isAutoApproved && ( - subCommandsLowerFirstWordOnly.some(command => promptInjectionWarningCommandsLower.includes(command)) || - (isPowerShell(shell, os) && subCommandsLowerFirstWordOnly.some(command => promptInjectionWarningCommandsLowerPwshOnly.includes(command))) - )) { - disclaimer = new MarkdownString(`$(${Codicon.info.id}) ` + localize('runInTerminal.promptInjectionDisclaimer', 'Web content may contain malicious code or attempt prompt injection attacks.'), { supportThemeIcons: true }); - } - - let customActions: ToolConfirmationAction[] | undefined; - if (!isAutoApproved && isAutoApproveEnabled) { - customActions = generateAutoApproveActions(actualCommand, subCommands, { subCommandResults, commandLineResult }); + if (!isAutoApproved && isAutoApproveEnabled) { + customActions = generateAutoApproveActions(actualCommand, subCommands, { subCommandResults, commandLineResult }); + } } let shellType = basename(shell, '.exe'); @@ -985,7 +1000,7 @@ class BackgroundTerminalExecution extends Disposable { } } -class TerminalProfileFetcher { +export class TerminalProfileFetcher { readonly osBackend: Promise; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts new file mode 100644 index 00000000000..44d4a4446a0 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/treeSitterCommandParser.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BugIndicatingError } from '../../../../../base/common/errors.js'; +import { derived, waitForState } from '../../../../../base/common/observable.js'; +import { ITreeSitterLibraryService } from '../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js'; +import type { Language, Parser, Query } from '@vscode/tree-sitter-wasm'; + +export const enum TreeSitterCommandParserLanguage { + Bash = 'bash', + PowerShell = 'powershell', +} + +export class TreeSitterCommandParser { + private readonly _parser: Promise; + private readonly _queries: Map = new Map(); + + constructor( + @ITreeSitterLibraryService private readonly _treeSitterLibraryService: ITreeSitterLibraryService + ) { + this._parser = this._treeSitterLibraryService.getParserClass().then(ParserCtor => new ParserCtor()); + } + + async extractSubCommands(languageId: TreeSitterCommandParserLanguage, commandLine: string): Promise { + const parser = await this._parser; + const language = await waitForState(derived(reader => { + return this._treeSitterLibraryService.getLanguage(languageId, true, reader); + })); + parser.setLanguage(language); + + const tree = parser.parse(commandLine); + if (!tree) { + throw new BugIndicatingError('Failed to parse tree'); + } + + const query = await this._getQuery(language); + if (!query) { + throw new BugIndicatingError('Failed to create tree sitter query'); + } + + const captures = query.captures(tree.rootNode); + const subCommands = captures.map(e => e.node.text); + return subCommands; + } + + private async _getQuery(language: Language): Promise { + let query = this._queries.get(language); + if (!query) { + query = await this._treeSitterLibraryService.createQuery(language, '(command) @command'); + this._queries.set(language, query); + } + return query; + } +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index cba511d00cf..263012d096c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -257,24 +257,6 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('should split command line into subcommands', () => { - const commandLine = 'echo "Hello World" && ls -la || pwd'; - const expectedSubCommands = ['echo "Hello World"', 'ls -la', 'pwd']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - suite('bash/sh shell', () => { - test('should split on logical operators', () => { - const commandLine = 'echo test && ls -la || pwd'; - const expectedSubCommands = ['echo test', 'ls -la', 'pwd']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on pipes', () => { - const commandLine = 'ls -la | grep test | wc -l'; - const expectedSubCommands = ['ls -la', 'grep test', 'wc -l']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on semicolons', () => { - const commandLine = 'cd /tmp; ls -la; pwd'; - const expectedSubCommands = ['cd /tmp', 'ls -la', 'pwd']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on background operator', () => { - const commandLine = 'sleep 5 & echo done'; - const expectedSubCommands = ['sleep 5', 'echo done']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on redirection operators', () => { - const commandLine = 'echo test > output.txt && cat output.txt'; - const expectedSubCommands = ['echo test', 'output.txt', 'cat output.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on stderr redirection', () => { - const commandLine = 'command 2> error.log && echo success'; - const expectedSubCommands = ['command', 'error.log', 'echo success']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on append redirection', () => { - const commandLine = 'echo line1 >> file.txt && echo line2 >> file.txt'; - const expectedSubCommands = ['echo line1', 'file.txt', 'echo line2', 'file.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - }); - - suite('zsh shell', () => { - test('should split on zsh-specific operators', () => { - const commandLine = 'echo test <<< "input" && ls'; - const expectedSubCommands = ['echo test', '"input"', 'ls']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on process substitution', () => { - const commandLine = 'diff <(ls dir1) <(ls dir2)'; - const expectedSubCommands = ['diff', 'ls dir1)', 'ls dir2)']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on bidirectional redirection', () => { - const commandLine = 'command <> file.txt && echo done'; - const expectedSubCommands = ['command', 'file.txt', 'echo done']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should handle complex zsh command chains', () => { - const commandLine = 'ls | grep test && echo found || echo not found'; - const expectedSubCommands = ['ls', 'grep test', 'echo found', 'echo not found']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - }); - - suite('PowerShell', () => { - test('should not split on PowerShell logical operators', () => { - const commandLine = 'Get-ChildItem -and Get-Location -or Write-Host "test"'; - const expectedSubCommands = ['Get-ChildItem -and Get-Location -or Write-Host "test"']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on PowerShell pipes', () => { - const commandLine = 'Get-Process | Where-Object Name -eq "notepad" | Stop-Process'; - const expectedSubCommands = ['Get-Process', 'Where-Object Name -eq "notepad"', 'Stop-Process']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on PowerShell redirection', () => { - const commandLine = 'Get-Process > processes.txt && Get-Content processes.txt'; - const expectedSubCommands = ['Get-Process', 'processes.txt', 'Get-Content processes.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'pwsh.exe', OperatingSystem.Windows); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - }); - - suite('edge cases', () => { - test('should return single command when no operators present', () => { - const commandLine = 'echo "hello world"'; - const expectedSubCommands = ['echo "hello world"']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should handle empty command', () => { - const commandLine = ''; - const expectedSubCommands: string[] = []; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should trim whitespace from subcommands', () => { - const commandLine = 'echo test && ls -la || pwd'; - const expectedSubCommands = ['echo test', 'ls -la', 'pwd']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'sh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should handle multiple consecutive operators', () => { - const commandLine = 'echo test && && ls'; - const expectedSubCommands = ['echo test', 'ls']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should handle unknown shell as sh', () => { - const commandLine = 'echo test && ls -la'; - const expectedSubCommands = ['echo test', 'ls -la']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'unknown-shell', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - }); - - suite('shell type detection', () => { - test('should detect PowerShell variants', () => { - const commandLine = 'Get-Process ; Get-Location'; - const expectedSubCommands = ['Get-Process', 'Get-Location']; - - deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'powershell', OperatingSystem.Linux), expectedSubCommands); - deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows), expectedSubCommands); - deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'pwsh', OperatingSystem.Linux), expectedSubCommands); - deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'pwsh.exe', OperatingSystem.Windows), expectedSubCommands); - deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'powershell-preview', OperatingSystem.Linux), expectedSubCommands); - }); - - test('should detect zsh specifically', () => { - const commandLine = 'echo test <<< input'; - const expectedSubCommands = ['echo test', 'input']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should default to sh for other shells', () => { - const commandLine = 'echo test && ls'; - const expectedSubCommands = ['echo test', 'ls']; - - deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux), expectedSubCommands); - deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'dash', OperatingSystem.Linux), expectedSubCommands); - deepStrictEqual(splitCommandLineIntoSubCommands(commandLine, 'fish', OperatingSystem.Linux), expectedSubCommands); - }); - }); - - suite('redirection tests', () => { - suite('output redirection', () => { - test('should split on basic output redirection', () => { - const commandLine = 'echo hello > output.txt'; - const expectedSubCommands = ['echo hello', 'output.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on append redirection', () => { - const commandLine = 'echo hello >> output.txt'; - const expectedSubCommands = ['echo hello', 'output.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on multiple output redirections', () => { - const commandLine = 'ls > files.txt && cat files.txt > backup.txt'; - const expectedSubCommands = ['ls', 'files.txt', 'cat files.txt', 'backup.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on numbered file descriptor redirection', () => { - const commandLine = 'command 1> stdout.txt 2> stderr.txt'; - const expectedSubCommands = ['command', 'stdout.txt', 'stderr.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on stderr-only redirection', () => { - const commandLine = 'make 2> errors.log'; - const expectedSubCommands = ['make', 'errors.log']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on all output redirection (&>)', () => { - const commandLine = 'command &> all_output.txt'; - const expectedSubCommands = ['command', 'all_output.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - }); - - suite('input redirection', () => { - test('should split on input redirection', () => { - const commandLine = 'sort < input.txt'; - const expectedSubCommands = ['sort', 'input.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on numbered input redirection', () => { - const commandLine = 'program 0< input.txt'; - const expectedSubCommands = ['program', 'input.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on input/output combined', () => { - const commandLine = 'sort < input.txt > output.txt'; - const expectedSubCommands = ['sort', 'input.txt', 'output.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - }); - - suite('stream redirection', () => { - test('should split on stdout to stderr redirection', () => { - const commandLine = 'echo error 1>&2'; - const expectedSubCommands = ['echo error']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on stderr to stdout redirection', () => { - const commandLine = 'command 2>&1'; - const expectedSubCommands = ['command']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on stream redirection with numbered descriptors', () => { - const commandLine = 'exec 3>&1 && exec 4>&2'; - const expectedSubCommands = ['exec', 'exec']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on multiple stream redirections', () => { - const commandLine = 'command 2>&1 1>&3 3>&2'; - const expectedSubCommands = ['command']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - }); - - suite('here documents and here strings', () => { - test('should split on here document', () => { - const commandLine = 'cat << EOF && echo done'; - const expectedSubCommands = ['cat', 'EOF', 'echo done']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on here string (bash/zsh)', () => { - const commandLine = 'grep pattern <<< "search this text"'; - const expectedSubCommands = ['grep pattern', '"search this text"']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on numbered here string', () => { - const commandLine = 'command 3<<< "input data"'; - const expectedSubCommands = ['command', '"input data"']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - }); - - suite('bidirectional redirection', () => { - test('should split on read/write redirection', () => { - const commandLine = 'dialog <> /dev/tty1'; - const expectedSubCommands = ['dialog', '/dev/tty1']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on numbered bidirectional redirection', () => { - const commandLine = 'program 3<> data.file'; - const expectedSubCommands = ['program', 'data.file']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - }); - - suite('PowerShell redirection', () => { - test('should split on PowerShell output redirection', () => { - const commandLine = 'Get-Process > processes.txt'; - const expectedSubCommands = ['Get-Process', 'processes.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on PowerShell append redirection', () => { - const commandLine = 'Write-Output "log entry" >> log.txt'; - const expectedSubCommands = ['Write-Output "log entry"', 'log.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'pwsh.exe', OperatingSystem.Windows); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on PowerShell error stream redirection', () => { - const commandLine = 'Get-Content nonexistent.txt 2> errors.log'; - const expectedSubCommands = ['Get-Content nonexistent.txt', 'errors.log']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on PowerShell warning stream redirection', () => { - const commandLine = 'Get-Process 3> warnings.log'; - const expectedSubCommands = ['Get-Process', 'warnings.log']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'pwsh.exe', OperatingSystem.Windows); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on PowerShell verbose stream redirection', () => { - const commandLine = 'Get-ChildItem 4> verbose.log'; - const expectedSubCommands = ['Get-ChildItem', 'verbose.log']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on PowerShell debug stream redirection', () => { - const commandLine = 'Invoke-Command 5> debug.log'; - const expectedSubCommands = ['Invoke-Command', 'debug.log']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'pwsh.exe', OperatingSystem.Windows); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on PowerShell information stream redirection', () => { - const commandLine = 'Write-Information "info" 6> info.log'; - const expectedSubCommands = ['Write-Information "info"', 'info.log']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on PowerShell all streams redirection', () => { - const commandLine = 'Get-Process *> all_streams.log'; - const expectedSubCommands = ['Get-Process', 'all_streams.log']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'pwsh.exe', OperatingSystem.Windows); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on PowerShell stream to stream redirection', () => { - const commandLine = 'Write-Error "error" 2>&1'; - const expectedSubCommands = ['Write-Error "error"']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'powershell.exe', OperatingSystem.Windows); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - }); - - suite('complex redirection scenarios', () => { - test('should split on command with multiple redirections', () => { - const commandLine = 'command < input.txt > output.txt 2> errors.log'; - const expectedSubCommands = ['command', 'input.txt', 'output.txt', 'errors.log']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on redirection with pipes and logical operators', () => { - const commandLine = 'cat file.txt | grep pattern > results.txt && echo "Found" || echo "Not found" 2> errors.log'; - const expectedSubCommands = ['cat file.txt', 'grep pattern', 'results.txt', 'echo "Found"', 'echo "Not found"', 'errors.log']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on chained redirections', () => { - const commandLine = 'echo "step1" > temp.txt && cat temp.txt >> final.txt && rm temp.txt'; - const expectedSubCommands = ['echo "step1"', 'temp.txt', 'cat temp.txt', 'final.txt', 'rm temp.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should handle redirection with background processes', () => { - const commandLine = 'long_running_command > output.log 2>&1 & echo "started"'; - const expectedSubCommands = ['long_running_command', 'output.log', 'echo "started"']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - }); - - suite('zsh-specific redirection', () => { - test('should split on zsh noclobber override', () => { - const commandLine = 'echo "force" >! existing_file.txt'; - const expectedSubCommands = ['echo "force"', 'existing_file.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on zsh clobber override', () => { - const commandLine = 'echo "overwrite" >| protected_file.txt'; - const expectedSubCommands = ['echo "overwrite"', 'protected_file.txt']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on zsh process substitution for input', () => { - const commandLine = 'diff <(sort file1.txt) <(sort file2.txt)'; - const expectedSubCommands = ['diff', 'sort file1.txt)', 'sort file2.txt)']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test('should split on zsh multios', () => { - const commandLine = 'echo "test" | tee >(gzip > file1.gz) >(bzip2 > file1.bz2)'; - const expectedSubCommands = ['echo "test"', 'tee', '(gzip', 'file1.gz)', '(bzip2', 'file1.bz2)']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - }); - }); - - suite('complex command combinations', () => { - test('should handle mixed operators in order', () => { - const commandLine = 'ls | grep test && echo found > result.txt || echo failed'; - const expectedSubCommands = ['ls', 'grep test', 'echo found', 'result.txt', 'echo failed']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'bash', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - - test.skip('should handle subshells and braces', () => { - const commandLine = '(cd /tmp && ls) && { echo done; }'; - const expectedSubCommands = ['(cd /tmp', 'ls)', '{ echo done', '}']; - const actualSubCommands = splitCommandLineIntoSubCommands(commandLine, 'zsh', OperatingSystem.Linux); - deepStrictEqual(actualSubCommands, expectedSubCommands); - }); - }); -}); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts similarity index 88% rename from src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/runInTerminalTool.test.ts rename to src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index be45523bbe1..62723155742 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -24,6 +24,17 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../../../ import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.js'; import { count } from '../../../../../../base/common/strings.js'; import { ITerminalProfile } from '../../../../../../platform/terminal/common/terminal.js'; +import { ITreeSitterLibraryService } from '../../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js'; +import { TreeSitterLibraryService } from '../../../../../services/treeSitter/browser/treeSitterLibraryService.js'; +import { FileService } from '../../../../../../platform/files/common/fileService.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { Schemas } from '../../../../../../base/common/network.js'; + +// HACK: This test lives in electron-browser/ to ensure this node import works if the test is run in +// web tests https://github.com/microsoft/vscode/issues/272777 +// eslint-disable-next-line local/code-layering, local/code-import-patterns +import { DiskFileSystemProvider } from '../../../../../../platform/files/node/diskFileSystemProvider.js'; class TestRunInTerminalTool extends RunInTerminalTool { protected override _osBackend: Promise = Promise.resolve(OperatingSystem.Windows); @@ -42,6 +53,7 @@ suite('RunInTerminalTool', () => { let instantiationService: TestInstantiationService; let configurationService: TestConfigurationService; + let fileService: IFileService; let storageService: IStorageService; let terminalServiceDisposeEmitter: Emitter; let chatServiceDisposeEmitter: Emitter<{ sessionId: string; reason: 'cleared' }>; @@ -50,13 +62,25 @@ suite('RunInTerminalTool', () => { setup(() => { configurationService = new TestConfigurationService(); + + const logService = new NullLogService(); + fileService = store.add(new FileService(logService)); + const diskFileSystemProvider = store.add(new DiskFileSystemProvider(logService)); + store.add(fileService.registerProvider(Schemas.file, diskFileSystemProvider)); + setConfig(TerminalChatAgentToolsSettingId.EnableAutoApprove, true); terminalServiceDisposeEmitter = new Emitter(); chatServiceDisposeEmitter = new Emitter<{ sessionId: string; reason: 'cleared' }>(); instantiationService = workbenchInstantiationService({ configurationService: () => configurationService, + fileService: () => fileService, }, store); + + const treeSitterLibraryService = store.add(instantiationService.createInstance(TreeSitterLibraryService)); + treeSitterLibraryService.isTest = true; + instantiationService.stub(ITreeSitterLibraryService, treeSitterLibraryService); + instantiationService.stub(ILanguageModelToolsService, { getTools() { return []; @@ -69,7 +93,7 @@ suite('RunInTerminalTool', () => { onDidDisposeSession: chatServiceDisposeEmitter.event }); instantiationService.stub(ITerminalProfileResolverService, { - getDefaultProfile: async () => ({ path: 'pwsh' } as ITerminalProfile) + getDefaultProfile: async () => ({ path: 'bash' } as ITerminalProfile) }); storageService = instantiationService.get(IStorageService); @@ -268,32 +292,19 @@ suite('RunInTerminalTool', () => { 'HTTP_PROXY=proxy:8080 wget https://example.com', 'VAR1=value1 VAR2=value2 echo test', 'A=1 B=2 C=3 ./script.sh', - - // Dangerous patterns - 'echo $(whoami)', - 'ls $(pwd)', - 'echo `date`', - 'cat `which ls`', - 'echo ${HOME}', - 'ls {a,b,c}', - 'echo (Get-Date)', - - // Dangerous patterns - multi-line - 'echo "{\n}"', - 'echo @"\n{\n}"@', ]; suite('auto approved', () => { for (const command of autoApprovedTestCases) { test(command.replaceAll('\n', '\\n'), async () => { - assertAutoApproved(await executeToolTest({ command: command })); + assertAutoApproved(await executeToolTest({ command })); }); } }); suite('confirmation required', () => { for (const command of confirmationRequiredTestCases) { test(command.replaceAll('\n', '\\n'), async () => { - assertConfirmationRequired(await executeToolTest({ command: command })); + assertConfirmationRequired(await executeToolTest({ command })); }); } }); @@ -319,7 +330,7 @@ suite('RunInTerminalTool', () => { command: 'rm file.txt', explanation: 'Remove a file' }); - assertConfirmationRequired(result, 'Run `pwsh` command?'); + assertConfirmationRequired(result, 'Run `bash` command?'); }); test('should require confirmation for commands in deny list even if in allow list', async () => { @@ -332,7 +343,7 @@ suite('RunInTerminalTool', () => { command: 'rm dangerous-file.txt', explanation: 'Remove a dangerous file' }); - assertConfirmationRequired(result, 'Run `pwsh` command?'); + assertConfirmationRequired(result, 'Run `bash` command?'); }); test('should handle background commands with confirmation', async () => { @@ -345,7 +356,7 @@ suite('RunInTerminalTool', () => { explanation: 'Start watching for file changes', isBackground: true }); - assertConfirmationRequired(result, 'Run `pwsh` command? (background terminal)'); + assertConfirmationRequired(result, 'Run `bash` command? (background terminal)'); }); test('should auto-approve background commands in allow list', async () => { @@ -422,18 +433,6 @@ suite('RunInTerminalTool', () => { assertAutoApproved(result); }); - test('should handle commands with only whitespace', async () => { - setAutoApprove({ - echo: true - }); - - const result = await executeToolTest({ - command: ' \t\n ', - explanation: 'Whitespace only command' - }); - assertConfirmationRequired(result); - }); - test('should handle matchCommandLine: true patterns', async () => { setAutoApprove({ '/dangerous/': { approve: false, matchCommandLine: true }, @@ -504,7 +503,7 @@ suite('RunInTerminalTool', () => { explanation: 'Build the project' }); - assertConfirmationRequired(result, 'Run `pwsh` command?'); + assertConfirmationRequired(result, 'Run `bash` command?'); assertDropdownActions(result, [ { subCommand: 'npm run build' }, 'commandLine', @@ -548,7 +547,7 @@ suite('RunInTerminalTool', () => { explanation: 'Build the project' }); - assertConfirmationRequired(result, 'Run `pwsh` command?'); + assertConfirmationRequired(result, 'Run `bash` command?'); assertDropdownActions(result, [ 'configure', ]); @@ -560,7 +559,7 @@ suite('RunInTerminalTool', () => { explanation: 'Install dependencies and build' }); - assertConfirmationRequired(result, 'Run `pwsh` command?'); + assertConfirmationRequired(result, 'Run `bash` command?'); assertDropdownActions(result, [ { subCommand: ['npm install', 'npm run build'] }, 'commandLine', @@ -578,7 +577,7 @@ suite('RunInTerminalTool', () => { explanation: 'Run foo command and show first 20 lines' }); - assertConfirmationRequired(result, 'Run `pwsh` command?'); + assertConfirmationRequired(result, 'Run `bash` command?'); assertDropdownActions(result, [ { subCommand: 'foo' }, 'commandLine', @@ -610,7 +609,7 @@ suite('RunInTerminalTool', () => { explanation: 'Run multiple piped commands' }); - assertConfirmationRequired(result, 'Run `pwsh` command?'); + assertConfirmationRequired(result, 'Run `bash` command?'); assertDropdownActions(result, [ { subCommand: ['foo', 'bar'] }, 'commandLine', @@ -921,7 +920,7 @@ suite('RunInTerminalTool', () => { clearAutoApproveWarningAcceptedState(); - assertConfirmationRequired(await executeToolTest({ command: 'echo hello world' }), 'Run `pwsh` command?'); + assertConfirmationRequired(await executeToolTest({ command: 'echo hello world' }), 'Run `bash` command?'); }); test('should auto-approve commands when both auto-approve enabled and warning accepted', async () => { @@ -940,7 +939,7 @@ suite('RunInTerminalTool', () => { }); const result = await executeToolTest({ command: 'echo hello world' }); - assertConfirmationRequired(result, 'Run `pwsh` command?'); + assertConfirmationRequired(result, 'Run `bash` command?'); }); }); @@ -960,66 +959,25 @@ suite('RunInTerminalTool', () => { }); }); -}); + suite('TerminalProfileFetcher', () => { + suite('getCopilotProfile', () => { + (isWindows ? test : test.skip)('should return custom profile when configured', async () => { + runInTerminalTool.setBackendOs(OperatingSystem.Windows); + const customProfile = Object.freeze({ path: 'C:\\Windows\\System32\\powershell.exe', args: ['-NoProfile'] }); + setConfig(TerminalChatAgentToolsSettingId.TerminalProfileWindows, customProfile); -suite('TerminalProfileFetcher', () => { - const store = ensureNoDisposablesAreLeakedInTestSuite(); + const result = await runInTerminalTool.profileFetcher.getCopilotProfile(); + strictEqual(result, customProfile); + }); - let instantiationService: TestInstantiationService; - let configurationService: TestConfigurationService; - let testTool: TestRunInTerminalTool; + (isLinux ? test : test.skip)('should fall back to default shell when no custom profile is configured', async () => { + runInTerminalTool.setBackendOs(OperatingSystem.Linux); + setConfig(TerminalChatAgentToolsSettingId.TerminalProfileLinux, null); - setup(() => { - configurationService = new TestConfigurationService(); - - instantiationService = workbenchInstantiationService({ - configurationService: () => configurationService, - }, store); - instantiationService.stub(ILanguageModelToolsService, { - getTools() { - return []; - }, - }); - instantiationService.stub(ITerminalService, { - onDidDisposeInstance: new Emitter().event - }); - instantiationService.stub(IChatService, { - onDidDisposeSession: new Emitter<{ sessionId: string; reason: 'cleared' }>().event - }); - instantiationService.stub(ITerminalProfileResolverService, { - getDefaultProfile: async () => ({ path: 'pwsh' } as ITerminalProfile) - }); - - testTool = store.add(instantiationService.createInstance(TestRunInTerminalTool)); - }); - - function setConfig(key: string, value: unknown) { - configurationService.setUserConfiguration(key, value); - configurationService.onDidChangeConfigurationEmitter.fire({ - affectsConfiguration: () => true, - affectedKeys: new Set([key]), - source: ConfigurationTarget.USER, - change: null!, - }); - } - - suite('getCopilotProfile', () => { - (isWindows ? test : test.skip)('should return custom profile when configured', async () => { - testTool.setBackendOs(OperatingSystem.Windows); - const customProfile = Object.freeze({ path: 'C:\\Windows\\System32\\powershell.exe', args: ['-NoProfile'] }); - setConfig(TerminalChatAgentToolsSettingId.TerminalProfileWindows, customProfile); - - const result = await testTool.profileFetcher.getCopilotProfile(); - strictEqual(result, customProfile); - }); - - (isLinux ? test : test.skip)('should fall back to default shell when no custom profile is configured', async () => { - testTool.setBackendOs(OperatingSystem.Linux); - setConfig(TerminalChatAgentToolsSettingId.TerminalProfileLinux, null); - - const result = await testTool.profileFetcher.getCopilotProfile(); - strictEqual(typeof result, 'object'); - strictEqual((result as ITerminalProfile).path, 'pwsh'); // From the mock ITerminalProfileResolverService + const result = await runInTerminalTool.profileFetcher.getCopilotProfile(); + strictEqual(typeof result, 'object'); + strictEqual((result as ITerminalProfile).path, 'bash'); + }); }); }); }); diff --git a/src/vs/workbench/services/treeSitter/browser/treeSitterLibraryService.ts b/src/vs/workbench/services/treeSitter/browser/treeSitterLibraryService.ts index 6302e6c322c..b6e82609b2d 100644 --- a/src/vs/workbench/services/treeSitter/browser/treeSitterLibraryService.ts +++ b/src/vs/workbench/services/treeSitter/browser/treeSitterLibraryService.ts @@ -121,8 +121,8 @@ export class TreeSitterLibraryService extends Disposable implements ITreeSitterL return treeSitter.Parser; } - getLanguage(languageId: string, reader: IReader | undefined): Language | undefined { - if (!this.supportsLanguage(languageId, reader)) { + getLanguage(languageId: string, ignoreSupportsCheck: boolean, reader: IReader | undefined): Language | undefined { + if (!ignoreSupportsCheck && !this.supportsLanguage(languageId, reader)) { return undefined; } const lang = this._languagesCache.get(languageId).resolvedValue.read(reader); @@ -144,6 +144,11 @@ export class TreeSitterLibraryService extends Disposable implements ITreeSitterL const query = this._injectionQueries.get({ languageId, kind: 'highlights' }).read(reader); return query; } + + async createQuery(language: Language, querySource: string): Promise { + const treeSitter = await this._treeSitterImport.value; + return new treeSitter.Query(language, querySource); + } } async function tryReadFile(fileService: IFileService, uri: URI): Promise {