diff --git a/package.json b/package.json index dd4626df5a..3af9e17736 100644 --- a/package.json +++ b/package.json @@ -1824,6 +1824,18 @@ "icon": "$(terminal)", "category": "Copilot CLI" }, + { + "command": "github.copilot.chat.addExternalContext", + "title": "%github.copilot.command.addExternalContext%", + "category": "Chat", + "icon": "$(folder-library)" + }, + { + "command": "github.copilot.chat.manageExternalContexts", + "title": "%github.copilot.command.manageExternalContexts%", + "category": "Chat", + "icon": "$(list-selection)" + }, { "command": "github.copilot.chat.replay", "title": "Start Chat Replay", diff --git a/package.nls.json b/package.nls.json index d27128fa16..1800ddc2ab 100644 --- a/package.nls.json +++ b/package.nls.json @@ -30,6 +30,8 @@ "github.copilot.command.openUserPreferences": "Open User Preferences", "github.copilot.command.openMemoryFolder": "Open Memory Folder", "github.copilot.command.sendChatFeedback": "Send Chat Feedback", + "github.copilot.command.addExternalContext": "Add External Folder", + "github.copilot.command.manageExternalContexts": "Manage External Folders", "github.copilot.command.buildLocalWorkspaceIndex": "Build Local Workspace Index", "github.copilot.command.buildRemoteWorkspaceIndex": "Build Remote Workspace Index", "github.copilot.viewsWelcome.signIn": { diff --git a/src/extension/context/node/externalContextService.ts b/src/extension/context/node/externalContextService.ts new file mode 100644 index 0000000000..be6d938cdf --- /dev/null +++ b/src/extension/context/node/externalContextService.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as npath from 'path'; +import { createServiceIdentifier } from '../../../util/common/services'; +import { isEqual } from '../../../util/vs/base/common/resources'; +import { URI } from '../../../util/vs/base/common/uri'; +import { Emitter, Event } from '../../../util/vs/base/common/event'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { isWindows } from '../../../util/vs/base/common/platform'; + +const MAX_EXTERNAL_PATHS = 3; + +export const IExternalContextService = createServiceIdentifier('IExternalContextService'); + +export interface IExternalContextService { + readonly _serviceBrand: undefined; + readonly onDidChangeExternalContext: Event; + readonly maxExternalPaths: number; + getExternalPaths(): readonly URI[]; + addExternalPaths(paths: readonly URI[]): readonly URI[]; + replaceExternalPaths(paths: readonly URI[]): void; + removeExternalPath(path: URI): void; + clear(): void; + isExternalPath(uri: URI): boolean; +} + +export class ExternalContextService extends Disposable implements IExternalContextService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeExternalContext = this._register(new Emitter()); + readonly onDidChangeExternalContext: Event = this._onDidChangeExternalContext.event; + + readonly maxExternalPaths = MAX_EXTERNAL_PATHS; + + private readonly _paths = new Map(); + + getExternalPaths(): readonly URI[] { + return [...this._paths.values()]; + } + + addExternalPaths(paths: readonly URI[]): readonly URI[] { + const added: URI[] = []; + if (!paths.length) { + return added; + } + + for (const path of paths) { + if (this._paths.size >= MAX_EXTERNAL_PATHS) { + break; + } + const key = path.toString(); + if (!this._paths.has(key)) { + this._paths.set(key, path); + added.push(path); + } + } + if (added.length) { + this._onDidChangeExternalContext.fire(); + } + + return added; + } + + replaceExternalPaths(paths: readonly URI[]): void { + this._paths.clear(); + for (const path of paths) { + if (this._paths.size >= MAX_EXTERNAL_PATHS) { + break; + } + const key = path.toString(); + if (!this._paths.has(key)) { + this._paths.set(key, path); + } + } + this._onDidChangeExternalContext.fire(); + } + + removeExternalPath(path: URI): void { + for (const [key, storedPath] of this._paths) { + if (isEqual(storedPath, path)) { + this._paths.delete(key); + this._onDidChangeExternalContext.fire(); + return; + } + } + } + + clear(): void { + if (this._paths.size === 0) { + return; + } + this._paths.clear(); + this._onDidChangeExternalContext.fire(); + } + + isExternalPath(uri: URI): boolean { + const candidateComparable = this.toComparablePath(uri); + for (const stored of this._paths.values()) { + const storedComparable = this.toComparablePath(stored); + + if (candidateComparable === storedComparable) { + return true; + } + + if (this.isSubPath(candidateComparable, storedComparable) || this.isSubPath(storedComparable, candidateComparable)) { + return true; + } + } + return false; + } + + private toComparablePath(uri: URI): string { + const normalized = npath.normalize(uri.fsPath); + return isWindows ? normalized.toLowerCase() : normalized; + } + + private isSubPath(child: string, potentialParent: string): boolean { + const parentWithSep = potentialParent.endsWith(npath.sep) ? potentialParent : potentialParent + npath.sep; + return child.startsWith(parentWithSep); + } +} diff --git a/src/extension/context/node/test/externalContextService.spec.ts b/src/extension/context/node/test/externalContextService.spec.ts new file mode 100644 index 0000000000..f88b0104d9 --- /dev/null +++ b/src/extension/context/node/test/externalContextService.spec.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { ExternalContextService } from '../externalContextService'; + +function createUri(name: string): URI { + return URI.file(path.join(process.cwd(), 'external-context-tests', name)); +} + +describe('ExternalContextService', () => { + it('caps at max external paths', () => { + const service = new ExternalContextService(); + + service.addExternalPaths([ + createUri('one'), + createUri('two'), + createUri('three'), + createUri('four') + ]); + + expect(service.getExternalPaths()).toHaveLength(service.maxExternalPaths); + }); + + it('fires change event when paths are added', () => { + const service = new ExternalContextService(); + let fired = 0; + + service.onDidChangeExternalContext(() => fired++); + + service.addExternalPaths([createUri('one')]); + + expect(fired).toBe(1); + }); + + it('removes paths and fires event', () => { + const service = new ExternalContextService(); + const [added] = service.addExternalPaths([createUri('one')]); + let fired = 0; + + service.onDidChangeExternalContext(() => fired++); + + service.removeExternalPath(added); + + expect(service.getExternalPaths()).toHaveLength(0); + expect(fired).toBe(1); + }); +}); + diff --git a/src/extension/context/vscode-node/externalContextContribution.ts b/src/extension/context/vscode-node/externalContextContribution.ts new file mode 100644 index 0000000000..985428f035 --- /dev/null +++ b/src/extension/context/vscode-node/externalContextContribution.ts @@ -0,0 +1,266 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as vscode from 'vscode'; +import { minimatch } from 'minimatch'; +import { URI } from '../../../util/vs/base/common/uri'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { IExtensionContribution } from '../../common/contributions'; +import { IExternalContextService } from '../node/externalContextService'; + +interface ExternalContextQuickPickItem extends vscode.QuickPickItem { + readonly uri?: URI; + readonly isBrowse?: boolean; +} + +interface PickResult { + readonly accepted: URI[]; + readonly excluded: URI[]; +} + +const ADD_COMMAND_ID = 'github.copilot.chat.addExternalContext'; +const MANAGE_COMMAND_ID = 'github.copilot.chat.manageExternalContexts'; +const STATUS_ALIGNMENT = vscode.StatusBarAlignment.Right; +const STATUS_PRIORITY = 100; +const DISALLOWED_FOLDER_NAMES = new Set(['node_modules', '.git', 'dist', 'build']); +const DISALLOWED_EXTENSIONS = new Set(['.log', '.tmp']); + +export class ExternalContextContribution extends Disposable implements IExtensionContribution { + readonly id = 'externalContext.contribution'; + + private readonly statusItem: vscode.StatusBarItem; + + constructor( + @IExternalContextService private readonly externalContextService: IExternalContextService, + ) { + super(); + + this.statusItem = this._register(vscode.window.createStatusBarItem(STATUS_ALIGNMENT, STATUS_PRIORITY)); + this.statusItem.name = vscode.l10n.t('Copilot External Context'); + this.statusItem.command = MANAGE_COMMAND_ID; + this.statusItem.tooltip = vscode.l10n.t('Manage external folders shared with Copilot'); + this.statusItem.show(); + this.updateStatus(); + + this._register(vscode.commands.registerCommand(ADD_COMMAND_ID, async () => this.handleAddExternalContext())); + this._register(vscode.commands.registerCommand(MANAGE_COMMAND_ID, async () => this.handleManageExternalContexts())); + this._register(this.externalContextService.onDidChangeExternalContext(() => this.updateStatus())); + } + + private updateStatus(): void { + const count = this.externalContextService.getExternalPaths().length; + const max = this.externalContextService.maxExternalPaths; + this.statusItem.text = `$(folder) ${count}/${max}`; + } + + private async handleAddExternalContext(): Promise { + const remaining = this.externalContextService.maxExternalPaths - this.externalContextService.getExternalPaths().length; + if (remaining <= 0) { + void vscode.window.showWarningMessage(vscode.l10n.t('Maximum of {0} external folders reached.', this.externalContextService.maxExternalPaths.toString())); + return; + } + + const pick = await this.pickExternalPaths(); + if (!pick || pick.accepted.length === 0) { + if (pick && pick.excluded.length) { + this.showExclusionMessage(pick.excluded); + } + return; + } + + const added = this.externalContextService.addExternalPaths(pick.accepted); + if (!added.length) { + void vscode.window.showWarningMessage(vscode.l10n.t('No external folders were added. The maximum of {0} folders may already be reached or your selection was excluded.', this.externalContextService.maxExternalPaths.toString())); + return; + } + + const label = added.length === 1 ? added[0].fsPath : vscode.l10n.t('{0} folders', added.length.toString()); + void vscode.window.setStatusBarMessage(vscode.l10n.t('Added {0} to Copilot external context', label), 3000); + + if (pick.excluded.length) { + this.showExclusionMessage(pick.excluded); + } + + if (added.length < pick.accepted.length || this.externalContextService.getExternalPaths().length >= this.externalContextService.maxExternalPaths) { + void vscode.window.showWarningMessage(vscode.l10n.t('Maximum of {0} external folders reached.', this.externalContextService.maxExternalPaths.toString())); + } + } + + private async handleManageExternalContexts(): Promise { + const current = this.externalContextService.getExternalPaths(); + if (!current.length) { + void vscode.window.showInformationMessage(vscode.l10n.t('No external folders are currently shared with Copilot.')); + return; + } + + const items = current.map(uri => ({ + label: uri.fsPath, + description: vscode.l10n.t('Remove from Copilot external context'), + uri + })); + + const picked = await vscode.window.showQuickPick(items, { + canPickMany: true, + ignoreFocusOut: true, + placeHolder: vscode.l10n.t('Select folders to remove from Copilot external context') + }); + + if (!picked || picked.length === 0) { + return; + } + + for (const item of picked) { + if (item.uri) { + this.externalContextService.removeExternalPath(item.uri); + } + } + + const removedLabel = picked.length === 1 && picked[0].uri ? picked[0].uri.fsPath : vscode.l10n.t('{0} folders', picked.length.toString()); + void vscode.window.setStatusBarMessage(vscode.l10n.t('Removed {0} from Copilot external context', removedLabel), 3000); + } + + private async pickExternalPaths(): Promise { + const items = await this.getQuickPickItems(); + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: vscode.l10n.t('Select folders to include in Copilot external context'), + ignoreFocusOut: true, + canPickMany: true, + }); + + if (!picked) { + return undefined; + } + + const selected: URI[] = []; + for (const item of picked) { + if (item.uri) { + selected.push(item.uri); + } + } + + if (picked.some(item => item.isBrowse)) { + const browseUris = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: true, + openLabel: vscode.l10n.t('Add to Copilot external context') + }); + + if (browseUris) { + for (const uri of browseUris) { + selected.push(URI.revive(uri)); + } + } + } + + if (!selected.length) { + return { accepted: [], excluded: [] }; + } + + const normalized = this.normalizeAndDeduplicate(selected); + return this.filterExcluded(normalized); + } + + private async getQuickPickItems(): Promise { + const items: ExternalContextQuickPickItem[] = []; + const envPaths = this.getEnvConfiguredPaths(); + + for (const uri of envPaths) { + items.push({ + label: uri.fsPath, + description: vscode.l10n.t('From EXTERNAL_CONTEXT_PATHS setting'), + uri + }); + } + + items.sort((a, b) => a.label.localeCompare(b.label)); + + items.push({ + label: vscode.l10n.t('Browse for folder…'), + description: vscode.l10n.t('Select a custom folder'), + isBrowse: true + }); + + return items; + } + + private getEnvConfiguredPaths(): URI[] { + const envValue = process.env.EXTERNAL_CONTEXT_PATHS || process.env.SYSTEM_CONTEXT_PATHS; + if (!envValue) { + return []; + } + + const segments = envValue.split(path.delimiter) + .map(segment => segment.trim()) + .filter(segment => segment.length > 0); + + return segments.map(segment => URI.file(path.resolve(segment))); + } + + private normalizeAndDeduplicate(uris: readonly URI[]): URI[] { + const unique = new Map(); + for (const uri of uris) { + unique.set(uri.with({ fragment: '', query: '' }).toString(), uri); + } + return Array.from(unique.values()); + } + + private async filterExcluded(uris: readonly URI[]): Promise { + const patterns = await this.getWorkspaceExcludePatterns(); + const accepted: URI[] = []; + const excluded: URI[] = []; + + for (const uri of uris) { + if (this.shouldExclude(uri, patterns)) { + excluded.push(uri); + } else { + accepted.push(uri); + } + } + + return { accepted, excluded }; + } + + private async getWorkspaceExcludePatterns(): Promise { + const config = vscode.workspace.getConfiguration('files'); + const excludes = config.get>('exclude'); + if (!excludes) { + return []; + } + + return Object.entries(excludes) + .filter(([, value]) => value === true) + .map(([pattern]) => pattern); + } + + private shouldExclude(uri: URI, excludePatterns: readonly string[]): boolean { + const fsPath = uri.fsPath; + const segments = fsPath.split(path.sep).map(segment => segment.toLowerCase()); + if (segments.some(segment => DISALLOWED_FOLDER_NAMES.has(segment))) { + return true; + } + + const ext = path.extname(fsPath).toLowerCase(); + if (DISALLOWED_EXTENSIONS.has(ext)) { + return true; + } + + const normalized = fsPath.replace(/\\+/g, '/'); + for (const pattern of excludePatterns) { + if (minimatch(normalized, pattern, { dot: true })) { + return true; + } + } + + return false; + } + + private showExclusionMessage(excluded: readonly URI[]): void { + const label = excluded.length === 1 ? excluded[0].fsPath : vscode.l10n.t('{0} folders', excluded.length.toString()); + void vscode.window.showWarningMessage(vscode.l10n.t('Skipped {0} because the folder is excluded or disallowed for Copilot external context.', label)); + } +} diff --git a/src/extension/extension/vscode-node/contributions.ts b/src/extension/extension/vscode-node/contributions.ts index 5ae36ce3a9..824d417155 100644 --- a/src/extension/extension/vscode-node/contributions.ts +++ b/src/extension/extension/vscode-node/contributions.ts @@ -19,6 +19,7 @@ import { ConversationFeature } from '../../conversation/vscode-node/conversation import { FeedbackCommandContribution } from '../../conversation/vscode-node/feedbackContribution'; import { LanguageModelAccess } from '../../conversation/vscode-node/languageModelAccess'; import { LogWorkspaceStateContribution } from '../../conversation/vscode-node/logWorkspaceState'; +import { ExternalContextContribution } from '../../context/vscode-node/externalContextContribution'; import { RemoteAgentContribution } from '../../conversation/vscode-node/remoteAgents'; import { LanguageModelProxyContrib } from '../../externalAgents/vscode-node/lmProxyContrib'; import { WalkthroughCommandContribution } from '../../getting-started/vscode-node/commands'; @@ -70,6 +71,7 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [ asContributionFactory(CopilotDebugCommandContribution), asContributionFactory(DebugCommandsContribution), asContributionFactory(LanguageModelAccess), + asContributionFactory(ExternalContextContribution), asContributionFactory(WalkthroughCommandContribution), asContributionFactory(InlineEditProviderFeature), asContributionFactory(SettingsSchemaFeature), diff --git a/src/extension/extension/vscode-node/services.ts b/src/extension/extension/vscode-node/services.ts index 85d1edae35..1c3acc4044 100644 --- a/src/extension/extension/vscode-node/services.ts +++ b/src/extension/extension/vscode-node/services.ts @@ -73,6 +73,7 @@ import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/d import { CommandServiceImpl, ICommandService } from '../../commands/node/commandService'; import { ApiEmbeddingsIndex, IApiEmbeddingsIndex } from '../../context/node/resolvers/extensionApi'; import { IPromptWorkspaceLabels, PromptWorkspaceLabels } from '../../context/node/resolvers/promptWorkspaceLabels'; +import { IExternalContextService, ExternalContextService } from '../../context/node/externalContextService'; import { ChatAgentService } from '../../conversation/vscode-node/chatParticipants'; import { FeedbackReporter } from '../../conversation/vscode-node/feedbackReporter'; import { IUserFeedbackService, UserFeedbackService } from '../../conversation/vscode-node/userActions'; @@ -176,6 +177,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio builder.define(IScopeSelector, new SyncDescriptor(ScopeSelectorImpl)); builder.define(IGitDiffService, new SyncDescriptor(GitDiffService)); builder.define(IGitCommitMessageService, new SyncDescriptor(GitCommitMessageServiceImpl)); + builder.define(IExternalContextService, new SyncDescriptor(ExternalContextService)); builder.define(IGithubRepositoryService, new SyncDescriptor(GithubRepositoryService)); builder.define(IDevContainerConfigurationService, new SyncDescriptor(DevContainerConfigurationServiceImpl)); builder.define(IChatAgentService, new SyncDescriptor(ChatAgentService)); diff --git a/src/extension/prompt/node/chatParticipantRequestHandler.ts b/src/extension/prompt/node/chatParticipantRequestHandler.ts index ba66ccfdb1..9cb3b0cf04 100644 --- a/src/extension/prompt/node/chatParticipantRequestHandler.ts +++ b/src/extension/prompt/node/chatParticipantRequestHandler.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as l10n from '@vscode/l10n'; -import type { ChatRequest, ChatRequestTurn2, ChatResponseStream, ChatResult, Location } from 'vscode'; +import type { ChatPromptReference, ChatRequest, ChatRequestTurn2, ChatResponseStream, ChatResult, Location } from 'vscode'; import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade'; import { getChatParticipantIdFromName, getChatParticipantNameFromId, workspaceAgentName } from '../../../platform/chat/common/chatAgents'; @@ -31,6 +31,7 @@ import { ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRe import { ICommandService } from '../../commands/node/commandService'; import { getAgentForIntent, Intent } from '../../common/constants'; import { IConversationStore } from '../../conversationStore/node/conversationStore'; +import { IExternalContextService } from '../../context/node/externalContextService'; import { IIntentService } from '../../intents/node/intentService'; import { UnknownIntent } from '../../intents/node/unknownIntent'; import { ContributedToolName } from '../../tools/common/toolNames'; @@ -44,6 +45,8 @@ import { IntentDetector } from './intentDetector'; import { CommandDetails } from './intentRegistry'; import { IIntent } from './intents'; +const EXTERNAL_CONTEXT_REFERENCE_PREFIX = 'vscode.prompt.file.external'; + export interface IChatAgentArgs { agentName: string; agentId: string; @@ -80,6 +83,7 @@ export class ChatParticipantRequestHandler { @IIgnoreService private readonly _ignoreService: IIgnoreService, @IIntentService private readonly _intentService: IIntentService, @IConversationStore private readonly _conversationStore: IConversationStore, + @IExternalContextService private readonly _externalContextService: IExternalContextService, @ITabsAndEditorsService tabsAndEditorsService: ITabsAndEditorsService, @ILogService private readonly _logService: ILogService, @IAuthenticationService private readonly _authService: IAuthenticationService, @@ -219,6 +223,9 @@ export class ChatParticipantRequestHandler { // sanitize the variables of all requests // this is done here because all intents must honor ignored files this.request = await this.sanitizeVariables(); + this.turn.request.message = this.request.prompt; + + this.appendExternalContextReferences(); const command = this.chatAgentArgs.intentId ? this._commandService.getCommand(this.chatAgentArgs.intentId, this.location) : @@ -282,6 +289,53 @@ export class ChatParticipantRequestHandler { } } + private appendExternalContextReferences(): void { + const externalUris = this._externalContextService.getExternalPaths(); + if (!externalUris.length) { + return; + } + + const existingRefs = this.request.references ?? []; + + const newRefs: ChatPromptReference[] = []; + let counter = 0; + for (const uri of externalUris) { + const alreadyPresent = existingRefs.some(ref => this.matchesReference(ref, uri)); + if (!alreadyPresent) { + const id = `${EXTERNAL_CONTEXT_REFERENCE_PREFIX}.${counter++}`; + newRefs.push({ + id, + name: uri.fsPath, + value: uri, + modelDescription: `External context path ${uri.fsPath}`, + }); + } + } + + if (!newRefs.length) { + return; + } + + this.request = { + ...this.request, + references: [...existingRefs, ...newRefs] + } as ChatRequest; + + } + + private matchesReference(reference: ChatPromptReference, candidate: URI): boolean { + const value = reference.value; + if (URI.isUri(value)) { + return isEqual(value, candidate); + } + + if (isLocation(value)) { + return isEqual(value.uri, candidate); + } + + return false; + } + private async selectIntent(command: CommandDetails | undefined, history: Turn[]): Promise { if (!command?.intent && this.location === ChatLocation.Editor) { // TODO@jrieken do away with location specific code diff --git a/src/extension/tools/node/listDirTool.tsx b/src/extension/tools/node/listDirTool.tsx index d0d190e5b6..2f9579b57d 100644 --- a/src/extension/tools/node/listDirTool.tsx +++ b/src/extension/tools/node/listDirTool.tsx @@ -14,6 +14,7 @@ import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { normalizePath } from '../../../util/vs/base/common/resources'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { LanguageModelPromptTsxPart, LanguageModelToolResult, MarkdownString } from '../../../vscodeTypes'; +import { IExternalContextService } from '../../context/node/externalContextService'; import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; import { ToolName } from '../common/toolNames'; import { ToolRegistry } from '../common/toolsRegistry'; @@ -31,12 +32,14 @@ class ListDirTool implements vscode.LanguageModelTool { @IInstantiationService private readonly instantiationService: IInstantiationService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, @IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService, + @IExternalContextService private readonly externalContextService: IExternalContextService, ) { } async invoke(options: vscode.LanguageModelToolInvocationOptions, token: CancellationToken) { const uri = resolveToolInputPath(options.input.path, this.promptPathRepresentationService); - const relativeToWorkspace = this.workspaceService.getWorkspaceFolder(normalizePath(uri)); - if (!relativeToWorkspace) { + const workspaceFolder = this.workspaceService.getWorkspaceFolder(normalizePath(uri)); + const isExternalPath = this.externalContextService.isExternalPath(uri); + if (!workspaceFolder && !isExternalPath) { throw new Error(`Directory ${options.input.path} is outside of the workspace and can't be read`); } diff --git a/src/extension/tools/node/toolUtils.ts b/src/extension/tools/node/toolUtils.ts index 148da700ec..d6880ee5e5 100644 --- a/src/extension/tools/node/toolUtils.ts +++ b/src/extension/tools/node/toolUtils.ts @@ -20,6 +20,7 @@ import { URI } from '../../../util/vs/base/common/uri'; import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation'; import { LanguageModelPromptTsxPart, LanguageModelToolResult, Location } from '../../../vscodeTypes'; import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; +import { IExternalContextService } from '../../context/node/externalContextService'; export function checkCancellation(token: CancellationToken): void { if (token.isCancellationRequested) { @@ -114,10 +115,13 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI): const tabsAndEditorsService = accessor.get(ITabsAndEditorsService); const promptPathRepresentationService = accessor.get(IPromptPathRepresentationService); const customInstructionsService = accessor.get(ICustomInstructionsService); + const externalContextService = accessor.get(IExternalContextService); await assertFileNotContentExcluded(accessor, uri); - if (!workspaceService.getWorkspaceFolder(normalizePath(uri)) && !customInstructionsService.isExternalInstructionsFile(uri) && uri.scheme !== Schemas.untitled) { + const isExternalPath = externalContextService.isExternalPath(uri); + + if (!isExternalPath && !workspaceService.getWorkspaceFolder(normalizePath(uri)) && !customInstructionsService.isExternalInstructionsFile(uri) && uri.scheme !== Schemas.untitled) { const fileOpenInSomeTab = tabsAndEditorsService.tabs.some(tab => isEqual(tab.uri, uri)); if (!fileOpenInSomeTab) { throw new Error(`File ${promptPathRepresentationService.getFilePath(uri)} is outside of the workspace, and not open in an editor, and can't be read`);