Merge pull request #277522 from microsoft/tyriar/260819

Allow approving terminal tool for session
This commit is contained in:
Daniel Imms
2025-11-15 10:47:59 -08:00
committed by GitHub
10 changed files with 182 additions and 10 deletions

View File

@@ -30,6 +30,7 @@ import { IKeybindingService } from '../../../../../../platform/keybinding/common
import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js';
import { IPreferencesService } from '../../../../../services/preferences/common/preferences.js';
import { ITerminalChatService } from '../../../../terminal/browser/terminal.js';
import { TerminalContribSettingId } from '../../../../terminal/terminalContribExports.js';
import { migrateLegacyTerminalToolSpecificData } from '../../../common/chat.js';
import { ChatContextKeys } from '../../../common/chatContextKeys.js';
@@ -42,7 +43,7 @@ import { ChatCustomConfirmationWidget, IChatConfirmationButton } from '../chatCo
import { EditorPool } from '../chatContentCodePools.js';
import { IChatContentPartRenderContext } from '../chatContentParts.js';
import { ChatMarkdownContentPart } from '../chatMarkdownContentPart.js';
import { openTerminalSettingsLinkCommandId } from './chatTerminalToolProgressPart.js';
import { disableSessionAutoApprovalCommandId, openTerminalSettingsLinkCommandId } from './chatTerminalToolProgressPart.js';
import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js';
export const enum TerminalToolConfirmationStorageKeys {
@@ -61,7 +62,8 @@ export type TerminalNewAutoApproveButtonData = (
{ type: 'enable' } |
{ type: 'configure' } |
{ type: 'skip' } |
{ type: 'newRule'; rule: ITerminalNewAutoApproveRule | ITerminalNewAutoApproveRule[] }
{ type: 'newRule'; rule: ITerminalNewAutoApproveRule | ITerminalNewAutoApproveRule[] } |
{ type: 'sessionApproval' }
);
export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationSubPart {
@@ -87,6 +89,7 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
@IPreferencesService private readonly preferencesService: IPreferencesService,
@IStorageService private readonly storageService: IStorageService,
@ITerminalChatService private readonly terminalChatService: ITerminalChatService,
@ITextModelService textModelService: ITextModelService,
@IHoverService hoverService: IHoverService,
) {
@@ -302,6 +305,19 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS
doComplete = false;
break;
}
case 'sessionApproval': {
const sessionId = this.context.element.sessionId;
this.terminalChatService.setChatSessionAutoApproval(sessionId, true);
const disableUri = createCommandUri(disableSessionAutoApprovalCommandId, sessionId);
const mdTrustSettings = {
isTrusted: {
enabledCommands: [disableSessionAutoApprovalCommandId]
}
};
terminalData.autoApproveInfo = new MarkdownString(`${localize('sessionApproval', 'All commands will be auto approved for this session')} ([${localize('sessionApproval.disable', 'Disable')}](${disableUri.toString()}))`, mdTrustSettings);
toolConfirmKind = ToolConfirmKind.UserAction;
break;
}
}
}

View File

@@ -861,6 +861,7 @@ MenuRegistry.appendMenuItem(MenuId.CommandPalette, {
});
export const openTerminalSettingsLinkCommandId = '_chat.openTerminalSettingsLink';
export const disableSessionAutoApprovalCommandId = '_chat.disableSessionAutoApproval';
CommandsRegistry.registerCommand(openTerminalSettingsLinkCommandId, async (accessor, scopeRaw: string) => {
const preferencesService = accessor.get(IPreferencesService);
@@ -897,6 +898,11 @@ CommandsRegistry.registerCommand(openTerminalSettingsLinkCommandId, async (acces
}
});
CommandsRegistry.registerCommand(disableSessionAutoApprovalCommandId, async (accessor, chatSessionId: string) => {
const terminalChatService = accessor.get(ITerminalChatService);
terminalChatService.setChatSessionAutoApproval(chatSessionId, false);
});
class ToggleChatTerminalOutputAction extends Action implements IAction {
private _expanded = false;

View File

@@ -203,6 +203,20 @@ export interface ITerminalChatService {
* @returns The most recent progress part or undefined if none exist
*/
getMostRecentProgressPart(): IChatTerminalToolProgressPart | undefined;
/**
* Enable or disable auto approval for all commands in a specific session.
* @param chatSessionId The chat session ID
* @param enabled Whether to enable or disable session auto approval
*/
setChatSessionAutoApproval(chatSessionId: string, enabled: boolean): void;
/**
* Check if a session has auto approval enabled for all commands.
* @param chatSessionId The chat session ID
* @returns True if the session has auto approval enabled
*/
hasChatSessionAutoApproval(chatSessionId: string): boolean;
}
/**

View File

@@ -47,6 +47,12 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ
private readonly _hasToolTerminalContext: IContextKey<boolean>;
private readonly _hasHiddenToolTerminalContext: IContextKey<boolean>;
/**
* Tracks chat session IDs that have auto approval enabled for all commands. This is a temporary
* approval that lasts only for the duration of the session.
*/
private readonly _sessionAutoApprovalEnabled = new Set<string>();
constructor(
@ILogService private readonly _logService: ILogService,
@ITerminalService private readonly _terminalService: ITerminalService,
@@ -83,6 +89,11 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ
this._terminalInstancesByToolSessionId.delete(terminalToolSessionId);
this._toolSessionIdByTerminalInstance.delete(instance);
this._terminalInstanceListenersByToolSessionId.deleteAndDispose(terminalToolSessionId);
// Clean up session auto approval state
const sessionId = LocalChatSessionUri.parseLocalSessionId(e.sessionResource);
if (sessionId) {
this._sessionAutoApprovalEnabled.delete(sessionId);
}
this._persistToStorage();
this._updateHasToolTerminalContextKeys();
}
@@ -279,4 +290,16 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ
const hiddenTerminalCount = this.getToolSessionTerminalInstances(true).length;
this._hasHiddenToolTerminalContext.set(hiddenTerminalCount > 0);
}
setChatSessionAutoApproval(chatSessionId: string, enabled: boolean): void {
if (enabled) {
this._sessionAutoApprovalEnabled.add(chatSessionId);
} else {
this._sessionAutoApprovalEnabled.delete(chatSessionId);
}
}
hasChatSessionAutoApproval(chatSessionId: string): boolean {
return this._sessionAutoApprovalEnabled.has(chatSessionId);
}
}

View File

@@ -177,6 +177,18 @@ export function generateAutoApproveActions(commandLine: string, subCommands: str
actions.push(new Separator());
}
// Allow all commands for this session
actions.push({
label: localize('allowSession', 'Allow All Commands in this Session'),
tooltip: localize('allowSessionTooltip', 'Allow this tool to run in this session without confirmation.'),
data: {
type: 'sessionApproval'
} satisfies TerminalNewAutoApproveButtonData
});
actions.push(new Separator());
// Always show configure option
actions.push({
label: localize('autoApprove.configure', 'Configure Auto Approve...'),

View File

@@ -21,6 +21,7 @@ export interface ICommandLineAnalyzerOptions {
os: OperatingSystem;
treeSitterLanguage: TreeSitterCommandParserLanguage;
terminalToolSessionId: string;
chatSessionId: string | undefined;
}
export interface ICommandLineAnalyzerResult {

View File

@@ -10,6 +10,7 @@ import type { SingleOrMany } from '../../../../../../../base/common/types.js';
import { localize } from '../../../../../../../nls.js';
import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js';
import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js';
import { ITerminalChatService } from '../../../../../terminal/browser/terminal.js';
import { IStorageService, StorageScope } from '../../../../../../../platform/storage/common/storage.js';
import { TerminalToolConfirmationStorageKeys } from '../../../../../chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.js';
import { openTerminalSettingsLinkCommandId } from '../../../../../chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.js';
@@ -43,12 +44,29 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IInstantiationService instantiationService: IInstantiationService,
@IStorageService private readonly _storageService: IStorageService,
@ITerminalChatService private readonly _terminalChatService: ITerminalChatService,
) {
super();
this._commandLineAutoApprover = this._register(instantiationService.createInstance(CommandLineAutoApprover));
}
async analyze(options: ICommandLineAnalyzerOptions): Promise<ICommandLineAnalyzerResult> {
if (options.chatSessionId && this._terminalChatService.hasChatSessionAutoApproval(options.chatSessionId)) {
this._log('Session has auto approval enabled, auto approving command');
const disableUri = createCommandUri('_chat.disableSessionAutoApproval', options.chatSessionId);
const mdTrustSettings = {
isTrusted: {
enabledCommands: ['_chat.disableSessionAutoApproval']
}
};
return {
isAutoApproved: true,
isAutoApproveAllowed: true,
disclaimers: [],
autoApproveInfo: new MarkdownString(`${localize('autoApprove.session', 'Auto approved for this session')} ([${localize('autoApprove.session.disable', 'Disable')}](${disableUri.toString()}))`, mdTrustSettings),
};
}
let subCommands: string[] | undefined;
try {
subCommands = await this._treeSitterCommandParser.extractSubCommands(options.treeSitterLanguage, options.commandLine);

View File

@@ -412,6 +412,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
shell,
treeSitterLanguage: isPowerShell(shell, os) ? TreeSitterCommandParserLanguage.PowerShell : TreeSitterCommandParserLanguage.Bash,
terminalToolSessionId,
chatSessionId: context.chatSessionId,
};
const commandLineAnalyzerResults = await Promise.all(this._commandLineAnalyzers.map(e => e.analyze(commandLineAnalyzerOptions)));

View File

@@ -79,7 +79,8 @@ suite('CommandLineFileWriteAnalyzer', () => {
shell: 'bash',
os: OperatingSystem.Linux,
treeSitterLanguage: TreeSitterCommandParserLanguage.Bash,
terminalToolSessionId: 'test'
terminalToolSessionId: 'test',
chatSessionId: 'test',
};
const result = await analyzer.analyze(options);
@@ -146,7 +147,8 @@ suite('CommandLineFileWriteAnalyzer', () => {
shell: 'bash',
os: OperatingSystem.Linux,
treeSitterLanguage: TreeSitterCommandParserLanguage.Bash,
terminalToolSessionId: 'test'
terminalToolSessionId: 'test',
chatSessionId: 'test',
};
const result = await analyzer.analyze(options);
@@ -182,7 +184,8 @@ suite('CommandLineFileWriteAnalyzer', () => {
shell: 'pwsh',
os: OperatingSystem.Windows,
treeSitterLanguage: TreeSitterCommandParserLanguage.PowerShell,
terminalToolSessionId: 'test'
terminalToolSessionId: 'test',
chatSessionId: 'test',
};
const result = await analyzer.analyze(options);
@@ -257,7 +260,8 @@ suite('CommandLineFileWriteAnalyzer', () => {
shell: 'bash',
os: OperatingSystem.Linux,
treeSitterLanguage: TreeSitterCommandParserLanguage.Bash,
terminalToolSessionId: 'test'
terminalToolSessionId: 'test',
chatSessionId: 'test',
};
const result = await analyzer.analyze(options);
@@ -288,7 +292,8 @@ suite('CommandLineFileWriteAnalyzer', () => {
shell: 'bash',
os: OperatingSystem.Linux,
treeSitterLanguage: TreeSitterCommandParserLanguage.Bash,
terminalToolSessionId: 'test'
terminalToolSessionId: 'test',
chatSessionId: 'test',
};
const result = await analyzer.analyze(options);
@@ -316,7 +321,8 @@ suite('CommandLineFileWriteAnalyzer', () => {
shell: 'bash',
os: OperatingSystem.Linux,
treeSitterLanguage: TreeSitterCommandParserLanguage.Bash,
terminalToolSessionId: 'test'
terminalToolSessionId: 'test',
chatSessionId: 'test',
};
const result = await analyzer.analyze(options);

View File

@@ -33,11 +33,12 @@ import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/ch
import { IChatService, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService.js';
import { LocalChatSessionUri } from '../../../../chat/common/chatUri.js';
import { ILanguageModelToolsService, IPreparedToolInvocation, IToolInvocationPreparationContext, type ToolConfirmationAction } from '../../../../chat/common/languageModelToolsService.js';
import { ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js';
import { ITerminalChatService, ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js';
import { ITerminalProfileResolverService } from '../../../../terminal/common/terminal.js';
import { RunInTerminalTool, type IRunInTerminalInputParams } from '../../browser/tools/runInTerminalTool.js';
import { ShellIntegrationQuality } from '../../browser/toolTerminalCreator.js';
import { terminalChatAgentToolsConfiguration, TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js';
import { TerminalChatService } from '../../../chat/browser/terminalChatService.js';
class TestRunInTerminalTool extends RunInTerminalTool {
protected override _osBackend: Promise<OperatingSystem> = Promise.resolve(OperatingSystem.Windows);
@@ -81,6 +82,7 @@ suite('RunInTerminalTool', () => {
fileService: () => fileService,
}, store);
instantiationService.stub(ITerminalChatService, store.add(instantiationService.createInstance(TerminalChatService)));
instantiationService.stub(IWorkspaceContextService, workspaceContextService);
instantiationService.stub(IHistoryService, {
getLastActiveWorkspaceRoot: () => undefined
@@ -473,7 +475,7 @@ suite('RunInTerminalTool', () => {
suite('prepareToolInvocation - custom actions for dropdown', () => {
function assertDropdownActions(result: IPreparedToolInvocation | undefined, items: ({ subCommand: SingleOrMany<string> } | 'commandLine' | '---' | 'configure')[]) {
function assertDropdownActions(result: IPreparedToolInvocation | undefined, items: ({ subCommand: SingleOrMany<string> } | 'commandLine' | '---' | 'configure' | 'sessionApproval')[]) {
const actions = result?.confirmationMessages?.terminalCustomActions!;
ok(actions, 'Expected custom actions to be defined');
@@ -488,6 +490,9 @@ suite('RunInTerminalTool', () => {
if (item === 'configure') {
strictEqual(action.label, 'Configure Auto Approve...');
strictEqual(action.data.type, 'configure');
} else if (item === 'sessionApproval') {
strictEqual(action.label, 'Allow All Commands in this Session');
strictEqual(action.data.type, 'sessionApproval');
} else if (item === 'commandLine') {
strictEqual(action.label, 'Always Allow Exact Command Line');
strictEqual(action.data.type, 'newRule');
@@ -519,6 +524,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: 'npm run build' },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -533,6 +540,8 @@ suite('RunInTerminalTool', () => {
assertDropdownActions(result, [
{ subCommand: 'foo' },
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -560,6 +569,8 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result, 'Run `bash` command?');
assertDropdownActions(result, [
'sessionApproval',
'---',
'configure',
]);
});
@@ -575,6 +586,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: ['npm install', 'npm run build'] },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -593,6 +606,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: 'foo' },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -625,6 +640,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: ['foo', 'bar'] },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -640,6 +657,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: 'git status' },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -655,6 +674,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: 'npm test' },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -670,6 +691,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: 'npm run build' },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -685,6 +708,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: 'yarn run test' },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -700,6 +725,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: 'foo' },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -715,6 +742,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: 'npm run abc' },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -730,6 +759,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: ['npm run build', 'git status'] },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -745,6 +776,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: ['git push', 'echo'] },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -760,6 +793,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: ['git status', 'git log'] },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -775,6 +810,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: 'foo' },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -787,6 +824,8 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
'sessionApproval',
'---',
'configure',
]);
});
@@ -802,6 +841,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: 'npm test' },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -817,6 +858,8 @@ suite('RunInTerminalTool', () => {
{ subCommand: 'foo' },
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -831,6 +874,8 @@ suite('RunInTerminalTool', () => {
assertDropdownActions(result, [
'commandLine',
'---',
'sessionApproval',
'---',
'configure',
]);
});
@@ -847,6 +892,8 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
'sessionApproval',
'---',
'configure',
]);
});
@@ -989,6 +1036,34 @@ suite('RunInTerminalTool', () => {
});
});
suite('session auto approval', () => {
test('should auto approve all commands when session has auto approval enabled', async () => {
const sessionId = 'test-session-123';
const terminalChatService = instantiationService.get(ITerminalChatService);
const context: IToolInvocationPreparationContext = {
parameters: {
command: 'rm dangerous-file.txt',
explanation: 'Remove a file',
isBackground: false
} as IRunInTerminalInputParams,
chatSessionId: sessionId
} as IToolInvocationPreparationContext;
let result = await runInTerminalTool.prepareToolInvocation(context, CancellationToken.None);
assertConfirmationRequired(result);
terminalChatService.setChatSessionAutoApproval(sessionId, true);
result = await runInTerminalTool.prepareToolInvocation(context, CancellationToken.None);
assertAutoApproved(result);
const terminalData = result!.toolSpecificData as IChatTerminalToolInvocationData;
ok(terminalData.autoApproveInfo, 'Expected autoApproveInfo to be defined');
ok(terminalData.autoApproveInfo.value.includes('Auto approved for this session'), 'Expected session approval message');
});
});
suite('TerminalProfileFetcher', () => {
suite('getCopilotProfile', () => {
(isWindows ? test : test.skip)('should return custom profile when configured', async () => {