diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 764219262c1..725a14a607e 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -111,6 +111,7 @@ import { runSaveToPromptAction, SAVE_TO_PROMPT_SLASH_COMMAND_NAME } from './prom import { ChatDynamicVariableModel } from './contrib/chatDynamicVariables.js'; import { ChatAttachmentResolveService, IChatAttachmentResolveService } from './chatAttachmentResolveService.js'; import { registerLanguageModelActions } from './actions/chatLanguageModelActions.js'; +import { PromptUrlHandler } from './promptSyntax/promptUrlHandler.js'; // Register configuration const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -711,6 +712,7 @@ registerWorkbenchContribution2(ChatEditingEditorContextKeys.ID, ChatEditingEdito registerWorkbenchContribution2(ChatTransferContribution.ID, ChatTransferContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatContextContributions.ID, ChatContextContributions, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatResponseResourceFileSystemProvider.ID, ChatResponseResourceFileSystemProvider, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(PromptUrlHandler.ID, PromptUrlHandler, WorkbenchPhase.BlockRestore); registerChatActions(); registerChatCopyActions(); diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts index 8c99aa3d9da..6216d68f072 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts @@ -44,11 +44,6 @@ export async function askForPromptSourceFolder( return; } - // if there is only one folder and it's for new, no need to ask - if (!existingFolder && folders.length === 1) { - return folders[0]; - } - const pickOptions: IPickOptions = { placeHolder: existingFolder ? getPlaceholderStringforMove(type, isMove) : getPlaceholderStringforNew(type), canPickMany: false, diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptUrlHandler.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptUrlHandler.ts new file mode 100644 index 00000000000..9c40e9f90a0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptUrlHandler.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { streamToBuffer, VSBuffer } from '../../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IRequestService } from '../../../../../platform/request/common/request.js'; +import { IURLHandler, IURLService } from '../../../../../platform/url/common/url.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { askForPromptFileName } from './pickers/askForPromptName.js'; +import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js'; +import { getCleanPromptName } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { localize } from '../../../../../nls.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; + +// example URL: code-oss:chat-prompt/install?url=https://gist.githubusercontent.com/aeschli/43fe78babd5635f062aef0195a476aad/raw/dfd71f60058a4dd25f584b55de3e20f5fd580e63/filterEvenNumbers.prompt.md + +export class PromptUrlHandler extends Disposable implements IWorkbenchContribution, IURLHandler { + + static readonly ID = 'workbench.contrib.promptUrlHandler'; + + static readonly CONFIRM_INSTALL_STORAGE_KEY = 'security.promptForPromptProtocolHandling'; + + constructor( + @IURLService urlService: IURLService, + @INotificationService private readonly notificationService: INotificationService, + @IRequestService private readonly requestService: IRequestService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IFileService private readonly fileService: IFileService, + @IOpenerService private readonly openerService: IOpenerService, + @ILogService private readonly logService: ILogService, + @IDialogService private readonly dialogService: IDialogService, + @IStorageService private readonly storageService: IStorageService, + ) { + super(); + this._register(urlService.registerHandler(this)); + } + + async handleURL(uri: URI): Promise { + let promptType: PromptsType | undefined; + switch (uri.path) { + case 'chat-prompt/install': + promptType = PromptsType.prompt; + break; + case 'chat-instructions/install': + promptType = PromptsType.instructions; + break; + case 'chat-mode/install': + promptType = PromptsType.mode; + break; + default: + return false; + } + + try { + const query = decodeURIComponent(uri.query); + if (!query || !query.startsWith('url=')) { + return false; + } + + const urlString = query.substring(4); + const url = URI.parse(urlString); + if (url.scheme !== Schemas.https && url.scheme !== Schemas.http) { + this.logService.error(`[PromptUrlHandler] Invalid URL: ${urlString}`); + return false; + } + + if (await this.shouldBlockInstall(promptType, url)) { + return false; + } + + const result = await this.requestService.request({ type: 'GET', url: urlString }, CancellationToken.None); + if (result.res.statusCode !== 200) { + this.logService.error(`[PromptUrlHandler] Failed to fetch URL: ${urlString}`); + this.notificationService.error(localize('failed', 'Failed to fetch URL: {0}', urlString)); + return false; + } + + const responseData = (await streamToBuffer(result.stream)).toString(); + + const newFolder = await this.instantiationService.invokeFunction(askForPromptSourceFolder, promptType); + if (!newFolder) { + return false; + } + + const newName = await this.instantiationService.invokeFunction(askForPromptFileName, promptType, newFolder.uri, getCleanPromptName(url)); + if (!newName) { + return false; + } + + const promptUri = URI.joinPath(newFolder.uri, newName); + + await this.fileService.createFolder(newFolder.uri); + await this.fileService.createFile(promptUri, VSBuffer.fromString(responseData)); + + await this.openerService.open(promptUri); + return true; + + } catch (error) { + this.logService.error(`Error handling prompt URL ${uri.toString()}`, error); + return false; + } + } + + private async shouldBlockInstall(promptType: PromptsType, url: URI): Promise { + const location = url.with({ path: url.path.substring(0, url.path.indexOf('/', 1) + 1), query: undefined, fragment: undefined }).toString(); + const key = PromptUrlHandler.CONFIRM_INSTALL_STORAGE_KEY + '-' + location; + + if (this.storageService.getBoolean(key, StorageScope.APPLICATION, false)) { + return false; + } + + let uriLabel = url.toString(); + if (uriLabel.length > 50) { + uriLabel = `${uriLabel.substring(0, 35)}...${uriLabel.substring(uriLabel.length - 15)}`; + } + + const detail = new MarkdownString('', { supportHtml: true }); + detail.appendMarkdown(localize('confirmOpenDetail2', "This will access {0}.\n\n", `[${uriLabel}](${url.toString()})`)); + detail.appendMarkdown(localize('confirmOpenDetail1', "Do you want to continue by selecting a destination folder and name?\n\n")); + detail.appendMarkdown(localize('confirmOpenDetail3', "If you did not initiate this request, it may represent an attempted attack on your system. Unless you took an explicit action to initiate this request, you should press 'Cancel'")); + + let message: string; + switch (promptType) { + case PromptsType.prompt: + message = localize('confirmInstallPrompt', "An external application wants to create a prompt file with content from a URL."); + break; + case PromptsType.instructions: + message = localize('confirmInstallInstructions', "An external application wants to create an instructions file with content from a URL."); + break; + default: + message = localize('confirmInstallMode', "An external application wants to create a chat mode with content from a URL."); + break; + } + + const { confirmed, checkboxChecked } = await this.dialogService.confirm({ + type: 'warning', + primaryButton: localize({ key: 'confirmOpenButton', comment: ['&& denotes a mnemonic'] }, "&&Continue"), + message, + checkbox: { label: localize('confirmOpenDoNotAskAgain', "Do not show this message again for files from '{0}'", location) }, + custom: { + markdownDetails: [{ + markdown: detail + }] + } + }); + + if (checkboxChecked) { + this.storageService.store(key, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + return !confirmed; + + } +}