Path: blob/main/extensions/copilot/src/extension/inlineEdits/vscode-node/components/inlineEditDebugComponent.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Command, commands, ThemeIcon, window } from 'vscode';6import { ConfigKey } from '../../../../platform/configuration/common/configurationService';7import { InlineEditRequestLogContext } from '../../../../platform/inlineEdits/common/inlineEditLogContext';8import { TsExpr } from '../../../../platform/inlineEdits/common/utils/tsExpr';9import { LogEntry } from '../../../../platform/workspaceRecorder/common/workspaceLog';10import { assertNever } from '../../../../util/vs/base/common/assert';11import { Disposable } from '../../../../util/vs/base/common/lifecycle';12import { IObservable, ISettableObservable } from '../../../../util/vs/base/common/observableInternal';13import { basename, extname } from '../../../../util/vs/base/common/path';14import { openIssueReporter } from '../../../conversation/vscode-node/feedbackReporter';15import { XtabProvider } from '../../../xtab/node/xtabProvider';16import { defaultNextEditProviderId } from '../../node/createNextEditProvider';17import { DebugRecorder } from '../../node/debugRecorder';1819export const reportFeedbackCommandId = 'github.copilot.debug.inlineEdit.reportFeedback';20const pickProviderId = 'github.copilot.debug.inlineEdit.pickProvider';2122export type InlineCompletionCommand = { command: Command; icon: ThemeIcon };2324export class InlineEditDebugComponent extends Disposable {2526constructor(27private readonly _internalActionsEnabled: IObservable<boolean>,28private readonly _inlineEditsEnabled: IObservable<boolean>,29private readonly _debugRecorder: DebugRecorder,30private readonly _inlineEditsProviderId: ISettableObservable<string | undefined>,31) {32super();3334this._register(commands.registerCommand(reportFeedbackCommandId, async (args: { logContext: InlineEditRequestLogContext }) => {35if (!this._inlineEditsEnabled.get()) {36return;37}38const isInternalUser = this._internalActionsEnabled.get();3940const data = new SimpleMarkdownBuilder();4142data.appendLine(`# Inline Edits Debug Info`);4344if (!isInternalUser) {45// Public users46data.appendLine(args.logContext.toMinimalLog());47} else {48// Internal users49data.appendLine(args.logContext.toLogDocument());5051let logFilteredForSensitiveFiles: LogEntry[] | undefined;52{53const bookmark = args.logContext.recordingBookmark;54const log = this._debugRecorder.getRecentLog(bookmark);5556let hasRemovedSensitiveFilesFromHistory = false;57let sectionContent;58if (log === undefined) {59sectionContent = ['Could not get recording to generate stest (likely because there was no corresponding workspaceRoot for this file)'];60} else {61logFilteredForSensitiveFiles = filterLogForSensitiveFiles(log);62hasRemovedSensitiveFilesFromHistory = log.length !== logFilteredForSensitiveFiles.length;63const stest = generateSTest(logFilteredForSensitiveFiles);6465sectionContent = [66'```typescript',67stest,68'```'69];70}71const header = hasRemovedSensitiveFilesFromHistory ? 'STest (sensitive files removed)' : 'STest';72data.appendSection(header, sectionContent);73data.appendLine('');74}7576{77if (logFilteredForSensitiveFiles !== undefined) {78data.appendSection('Recording', ['```json', JSON.stringify(logFilteredForSensitiveFiles, undefined, 2), '```']);79}80}8182{83const uiRepro = await extractInlineEditRepro();84if (uiRepro) {85data.appendSection('UI Repro', ['```', uiRepro, '```']);86}87}88}8990await openIssueReporter({91title: '',92data: data.toString(),93issueBody: '# Description\nPlease describe the expected outcome and attach a screenshot!',94public: !isInternalUser95});96}));9798this._register(commands.registerCommand(pickProviderId, async (args: unknown) => {99if (!this._inlineEditsEnabled.get()) { return; }100if (!this._internalActionsEnabled.get()) { return; }101102const selectedProvider = await window.showQuickPick(this._getAvailableProviderIds(), { placeHolder: 'Select inline edits provider' });103if (!selectedProvider || selectedProvider === this._inlineEditsProviderId.get()) { return; }104105this._inlineEditsProviderId.set(selectedProvider, undefined);106107const pick = await window.showWarningMessage(`Inline edits provider set to ${selectedProvider}. Reloading will undo this change. Set "github.copilot.${ConfigKey.TeamInternal.InlineEditsProviderId.id}": "${selectedProvider}" in your settings file to make the change persistent.`, 'Open settings (JSON)');108if (!pick) { return; }109110await commands.executeCommand('workbench.action.openSettingsJson', { revealSetting: { key: `github.copilot.${ConfigKey.TeamInternal.InlineEditsProviderId.id}`, edit: true } });111}));112}113114getCommands(logContext: InlineEditRequestLogContext): InlineCompletionCommand[] {115const menuCommands: InlineCompletionCommand[] = [];116menuCommands.push({117command: {118command: reportFeedbackCommandId,119title: 'Feedback',120arguments: [{ logContext }],121},122icon: new ThemeIcon('feedback')123});124125if (this._internalActionsEnabled.get()) {126if (this._getAvailableProviderIds().length > 1) {127menuCommands.push({128command: {129command: pickProviderId,130title: `Model: ${this._inlineEditsProviderId.get() ?? defaultNextEditProviderId}`,131},132icon: new ThemeIcon('wand'),133});134}135}136137return menuCommands;138}139140private _getAvailableProviderIds(): string[] {141const providers = [XtabProvider.ID];142143const providerId = this._inlineEditsProviderId.get();144if (providerId && !providers.includes(providerId)) {145providers.push(providerId);146}147148return providers;149}150}151152function generateSTest(log: LogEntry[]): string {153return TsExpr.str`154stest({ description: 'MyTest', language: 'typescript' }, collection => tester.runAndScoreTestFromRecording(collection,155loadFile({156fileName: "MyTest/recording.w.json",157fileContents: ${JSON.stringify({ log })},158})159));160`.toString();161}162163/**164* Sensitive file patterns that should be filtered from logs to prevent165* accidental exposure of secrets, credentials, or private configuration.166*/167const SENSITIVE_FILE_PATTERNS = {168// Exact basename matches (case-insensitive)169exactNames: new Set([170'settings.json', // VS Code settings171'keybindings.json', // VS Code keybindings (may contain custom bindings)172'launch.json', // Debug configs often contain env vars with secrets173'.npmrc', // npm auth tokens174'.netrc', // Network credentials175'.htpasswd', // HTTP auth passwords176'.gitconfig', // Git config can contain tokens177'credentials', // Generic credentials file178'credentials.json',179'secrets.json',180'config.json', // Often contains API keys181'password.txt', // Plain text password files182'passwords.txt',183'password.json',184'passwords.json',185'token.json', // Token storage files186'tokens.json',187'token.txt',188'tokens.txt',189]),190191// File extensions that are sensitive (checked with endsWith)192extensions: [193'.env', // Files ending with .env (e.g., app.env, local.env)194'.pem', // Private keys195'.key', // Private keys196'.p12', // PKCS#12 certificates197'.pfx', // PKCS#12 certificates198],199200// Prefixes for dotfiles that are sensitive (e.g., .env, .env.local, .env.production)201sensitiveDotfilePrefixes: [202'.env', // Environment files (.env, .env.local, .env.development, etc.)203],204205// Path segments that indicate sensitive directories206sensitivePathSegments: [207'.aws', // AWS credentials208'.ssh', // SSH keys209'.gnupg', // GPG keys210'.docker', // Docker config with registry auth211],212213// Filename patterns (using includes)214patterns: [215'id_rsa', // SSH private keys216'id_ed25519', // SSH private keys217'id_ecdsa', // SSH private keys218'id_dsa', // SSH private keys219'.secret', // Files with .secret in name220'_secret', // Files with _secret in name221],222};223224/**225* Check if a file path represents a sensitive file that should be filtered.226*/227function isSensitiveFile(relativePath: string): boolean {228// Normalize path separators for consistent handling across platforms229const normalizedPath = relativePath.replace(/\\/g, '/');230const pathParts = normalizedPath.split('/');231232// Use basename/extname on normalized path for robust filename extraction233const fileName = basename(normalizedPath);234const fileNameLower = fileName.toLowerCase();235const fileExt = extname(normalizedPath).toLowerCase();236237// Check exact filename matches (case-insensitive)238if (SENSITIVE_FILE_PATTERNS.exactNames.has(fileNameLower)) {239return true;240}241242// Check file extensions (e.g., .pem, .key, .p12, .pfx, files ending in .env like app.env)243for (const ext of SENSITIVE_FILE_PATTERNS.extensions) {244if (fileExt === ext || fileNameLower.endsWith(ext)) {245return true;246}247}248249// Check sensitive dotfile prefixes (e.g., .env, .env.local, .env.production)250for (const prefix of SENSITIVE_FILE_PATTERNS.sensitiveDotfilePrefixes) {251if (fileNameLower === prefix || fileNameLower.startsWith(prefix + '.')) {252return true;253}254}255256// Check sensitive path segments257for (const segment of SENSITIVE_FILE_PATTERNS.sensitivePathSegments) {258if (pathParts.some(part => part === segment)) {259return true;260}261}262263// Check filename patterns264for (const pattern of SENSITIVE_FILE_PATTERNS.patterns) {265if (fileNameLower.includes(pattern)) {266return true;267}268}269270return false;271}272273export function filterLogForSensitiveFiles(log: LogEntry[]): LogEntry[] {274const sensitiveFileIds = new Set<number>();275276const safeEntries: LogEntry[] = [];277278for (const entry of log) {279switch (entry.kind) {280// safe entry281case 'meta':282case 'header':283case 'applicationStart':284case 'event':285case 'bookmark':286safeEntries.push(entry);287break;288289// check if newly encountered document is sensitive290// if so, add it to the sensitive file ids291// otherwise, add it to the safe entries292case 'documentEncountered': {293if (isSensitiveFile(entry.relativePath)) {294sensitiveFileIds.add(entry.id);295} else {296safeEntries.push(entry);297}298break;299}300301// ensure the entry doesn't belong to a sensitive file302case 'setContent':303case 'storeContent':304case 'restoreContent':305case 'opened':306case 'closed':307case 'changed':308case 'focused':309case 'selectionChanged':310case 'documentEvent': {311if (!sensitiveFileIds.has(entry.id)) {312safeEntries.push(entry);313}314break;315}316317default: {318assertNever(entry);319}320}321}322323return safeEntries;324}325326327async function extractInlineEditRepro() {328const commandId = 'editor.action.inlineSuggest.dev.extractRepro';329const result: { reproCase: string } | undefined = await commands.executeCommand(commandId);330return result?.reproCase;331}332333class SimpleMarkdownBuilder {334private readonly _lines: string[] = [];335336constructor() {337}338339appendLine(line: string): void {340this._lines.push(line);341}342343toString(): string {344return this._lines.join('\n');345}346347appendSection(header: string, lines: string[]): void {348this._lines.push(349`<details><summary>${header}</summary>`,350'', // we need separation between the summary and the content351...lines,352`</details>`353);354}355}356357358