Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/permissionHelpers.spec.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
import type { CancellationToken, ChatParticipantToolToken } from 'vscode';
8
import { ILogService } from '../../../../../platform/log/common/logService';
9
import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
10
import { CancellationTokenSource } from '../../../../../util/vs/base/common/cancellation';
11
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
12
import { URI } from '../../../../../util/vs/base/common/uri';
13
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
14
import { LanguageModelTextPart, LanguageModelToolResult2 } from '../../../../../vscodeTypes';
15
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
16
import { ToolName } from '../../../../tools/common/toolNames';
17
import { IToolsService } from '../../../../tools/common/toolsService';
18
import { ExternalEditTracker } from '../../../common/externalEditTracker';
19
import { IWorkspaceInfo } from '../../../common/workspaceInfo';
20
import { ICopilotCLIImageSupport } from '../copilotCLIImageSupport';
21
import { buildMcpConfirmationParams, buildShellConfirmationParams, handleReadPermission, handleWritePermission, isFileFromSessionWorkspace, PermissionRequest, requiresFileEditconfirmation, showInteractivePermissionPrompt } from '../permissionHelpers';
22
23
24
describe('CopilotCLI permissionHelpers', () => {
25
const disposables = new DisposableStore();
26
let instaService: IInstantiationService;
27
beforeEach(() => {
28
const services = disposables.add(createExtensionUnitTestingServices());
29
instaService = services.seal();
30
});
31
32
afterEach(() => {
33
disposables.clear();
34
});
35
36
describe('buildShellConfirmationParams', () => {
37
it('shell: uses intention over command text and sets terminal confirmation tool', () => {
38
const req = { kind: 'shell', intention: 'List workspace files', fullCommandText: 'ls -la' } as any;
39
const result = buildShellConfirmationParams(req, undefined);
40
expect(result.tool).toBe(ToolName.CoreTerminalConfirmationTool);
41
expect(result.input.message).toBe('List workspace files');
42
expect(result.input.command).toBe('ls -la');
43
expect(result.input.isBackground).toBe(false);
44
});
45
46
it('shell: falls back to fullCommandText when no intention', () => {
47
const req = { kind: 'shell', fullCommandText: 'echo "hi"' } as any;
48
const result = buildShellConfirmationParams(req, undefined);
49
expect(result.tool).toBe(ToolName.CoreTerminalConfirmationTool);
50
expect(result.input.message).toBe('echo "hi"');
51
expect(result.input.command).toBe('echo "hi"');
52
});
53
54
it('shell: falls back to codeBlock when neither intention nor command text provided', () => {
55
const req = { kind: 'shell' } as any;
56
const result = buildShellConfirmationParams(req, undefined);
57
expect(result.tool).toBe(ToolName.CoreTerminalConfirmationTool);
58
// codeBlock starts with two newlines then ```
59
expect(result.input.message).toMatch(/^\n\n```/);
60
});
61
62
it('shell: strips cd prefix from command when matching workingDirectory on bash', () => {
63
const workingDirectory = URI.file('/workspace');
64
const req = { kind: 'shell', fullCommandText: `cd ${workingDirectory.fsPath} && npm test` } as any;
65
const result = buildShellConfirmationParams(req, workingDirectory, false);
66
expect(result.input.command).toBe('npm test');
67
expect(result.input.message).toBe('npm test');
68
});
69
70
it('shell: keeps full command when cd prefix does not match workingDirectory on bash', () => {
71
const fullCommandText = `cd ${URI.file('/other').fsPath} && npm test`;
72
const req = { kind: 'shell', fullCommandText: fullCommandText } as any;
73
const workingDirectory = URI.file('/workspace');
74
const result = buildShellConfirmationParams(req, workingDirectory, false);
75
expect(result.input.command).toBe(fullCommandText);
76
expect(result.input.message).toBe(fullCommandText);
77
});
78
79
it('shell: keeps full command with cd prefix when no workingDirectory', () => {
80
const fullCommandText = 'cd /workspace && npm test';
81
const req = { kind: 'shell', fullCommandText: fullCommandText } as any;
82
const result = buildShellConfirmationParams(req, undefined, false);
83
expect(result.input.command).toBe(fullCommandText);
84
expect(result.input.message).toBe(fullCommandText);
85
});
86
87
it('shell: plain command without cd prefix is unchanged', () => {
88
const req = { kind: 'shell', fullCommandText: 'npm test' } as any;
89
const workingDirectory = URI.file('/workspace');
90
const result = buildShellConfirmationParams(req, workingDirectory, false);
91
expect(result.input.command).toBe('npm test');
92
expect(result.input.message).toBe('npm test');
93
});
94
95
it('shell: intention takes priority in message even when cd prefix is stripped', () => {
96
const workingDirectory = URI.file('/workspace');
97
const fullCommandText = `cd ${workingDirectory.fsPath} && npm test`;
98
const req = { kind: 'shell', intention: 'Run unit tests', fullCommandText: fullCommandText } as any;
99
const result = buildShellConfirmationParams(req, workingDirectory, false);
100
expect(result.input.message).toBe('Run unit tests');
101
expect(result.input.command).toBe('npm test');
102
});
103
104
it('shell: strips Set-Location prefix when matching workingDirectory on Windows', () => {
105
const workingDirectory = URI.file('C:\\workspace');
106
const fullCommandText = `Set-Location ${workingDirectory.fsPath}; npm test`;
107
const req = { kind: 'shell', fullCommandText: fullCommandText } as any;
108
const result = buildShellConfirmationParams(req, workingDirectory, true);
109
expect(result.input.command).toBe('npm test');
110
expect(result.input.message).toBe('npm test');
111
});
112
113
it('shell: strips cd /d prefix when matching workingDirectory on Windows', () => {
114
const workingDirectory = URI.file('C:\\project');
115
const fullCommandText = `cd /d ${workingDirectory.fsPath} && npm start`;
116
const req = { kind: 'shell', fullCommandText } as any;
117
const result = buildShellConfirmationParams(req, workingDirectory, true);
118
expect(result.input.command).toBe('npm start');
119
expect(result.input.message).toBe('npm start');
120
});
121
122
it('shell: strips Set-Location -Path prefix when matching workingDirectory on Windows', () => {
123
const workingDirectory = URI.file('C:\\project');
124
const fullCommandText = `Set-Location -Path ${workingDirectory.fsPath} && npm start`;
125
const req = { kind: 'shell', fullCommandText } as any;
126
const result = buildShellConfirmationParams(req, workingDirectory, true);
127
expect(result.input.command).toBe('npm start');
128
expect(result.input.message).toBe('npm start');
129
});
130
131
it('shell: bash cd prefix not recognized when isWindows is true', () => {
132
// On Windows, isPowershell=true, so bash-style `cd /workspace &&` may not match the powershell regex
133
const workingDirectory = URI.file('/workspace');
134
const fullCommandText = `cd ${workingDirectory.fsPath} && npm test`;
135
const req = { kind: 'shell', fullCommandText } as any;
136
const result = buildShellConfirmationParams(req, workingDirectory, true);
137
// Powershell regex does match `cd <dir> &&` pattern (cd without /d), so stripping still happens
138
expect(result.input.command).toBe('npm test');
139
});
140
141
it('shell: Windows Set-Location not recognized when isWindows is false', () => {
142
// On non-Windows, isPowershell=false, so Set-Location is not recognized
143
const workingDirectory = URI.file('C:\\workspace');
144
const fullCommandText = `Set-Location -Path ${workingDirectory.fsPath}; npm test`;
145
const req = { kind: 'shell', fullCommandText } as any;
146
const result = buildShellConfirmationParams(req, workingDirectory, false);
147
// Bash regex doesn't recognize Set-Location, so full command is kept
148
expect(result.input.command).toBe(fullCommandText);
149
});
150
});
151
152
describe('buildMcpConfirmationParams', () => {
153
it('mcp: formats with serverName, toolTitle and args JSON', () => {
154
const req = { kind: 'mcp', serverName: 'files', toolTitle: 'List Files', toolName: 'list', args: { path: '/tmp' } } as any;
155
const result = buildMcpConfirmationParams(req as Extract<PermissionRequest, { kind: 'mcp' }>);
156
expect(result.tool).toBe(ToolName.CoreConfirmationTool);
157
expect(result.input.title).toBe('List Files');
158
expect(result.input.message).toContain('Server: files');
159
expect(result.input.message).toContain('"path": "/tmp"');
160
});
161
162
it('mcp: falls back to generated title and full JSON when no serverName', () => {
163
const req = { kind: 'mcp', toolName: 'info', args: { detail: true } } as any;
164
const result = buildMcpConfirmationParams(req as Extract<PermissionRequest, { kind: 'mcp' }>);
165
expect(result.input.title).toBe('MCP Tool: info');
166
expect(result.input.message).toMatch(/```json/);
167
expect(result.input.message).toContain('"detail": true');
168
});
169
170
it('mcp: uses Unknown when neither toolTitle nor toolName provided', () => {
171
const req = { kind: 'mcp', args: {} } as any;
172
const result = buildMcpConfirmationParams(req as Extract<PermissionRequest, { kind: 'mcp' }>);
173
expect(result.input.title).toBe('MCP Tool: Unknown');
174
});
175
});
176
177
describe('requiresFileEditconfirmation', () => {
178
it('returns false for non-write requests', async () => {
179
const req = { kind: 'shell', fullCommandText: 'ls' } as any;
180
expect(await requiresFileEditconfirmation(instaService, req)).toBe(false);
181
});
182
183
it('returns false when no fileName is provided', async () => {
184
const req = { kind: 'write', intention: 'edit' } as any;
185
expect(await requiresFileEditconfirmation(instaService, req)).toBe(false);
186
});
187
188
it('requires confirmation for file outside workspace when no workingDirectory', async () => {
189
const req = { kind: 'write', fileName: URI.file('/some/path/foo.ts').fsPath, diff: '', intention: '' } as any;
190
expect(await requiresFileEditconfirmation(instaService, req)).toBe(true);
191
});
192
193
it('does not require confirmation when workingDirectory covers the file', async () => {
194
const req = { kind: 'write', fileName: URI.file('/workspace/src/foo.ts').fsPath, diff: '', intention: '' } as any;
195
const workingDirectory = URI.file('/workspace');
196
expect(await requiresFileEditconfirmation(instaService, req, undefined, workingDirectory)).toBe(false);
197
});
198
199
it('does not require confirmation when workingDirectory is provided', async () => {
200
const req = { kind: 'write', fileName: URI.file('/workspace/other/foo.ts').fsPath, diff: '', intention: '' } as any;
201
const workingDirectory = URI.file('/workspace');
202
// workingDirectory callback always returns the same folder, treating all files as in-workspace
203
expect(await requiresFileEditconfirmation(instaService, req, undefined, workingDirectory)).toBe(false);
204
});
205
});
206
207
describe('isFileFromSessionWorkspace', () => {
208
it('returns true for file inside the working directory (folder)', () => {
209
const workspaceInfo: IWorkspaceInfo = {
210
folder: URI.file('/workspace'),
211
repository: undefined,
212
worktree: undefined,
213
worktreeProperties: undefined,
214
};
215
expect(isFileFromSessionWorkspace(URI.file('/workspace/src/foo.ts'), workspaceInfo)).toBe(true);
216
});
217
218
it('returns false for file outside all known directories', () => {
219
const workspaceInfo: IWorkspaceInfo = {
220
folder: URI.file('/workspace'),
221
repository: undefined,
222
worktree: undefined,
223
worktreeProperties: undefined,
224
};
225
expect(isFileFromSessionWorkspace(URI.file('/other/path/foo.ts'), workspaceInfo)).toBe(false);
226
});
227
228
it('returns true for file inside the worktree', () => {
229
const workspaceInfo: IWorkspaceInfo = {
230
folder: URI.file('/workspace'),
231
repository: URI.file('/repo'),
232
worktree: URI.file('/worktree'),
233
worktreeProperties: { autoCommit: true, baseCommit: 'abc', branchName: 'test', repositoryPath: '/repo', worktreePath: '/worktree', version: 1 },
234
};
235
expect(isFileFromSessionWorkspace(URI.file('/worktree/src/foo.ts'), workspaceInfo)).toBe(true);
236
});
237
238
it('returns true for file inside repository when worktree exists', () => {
239
const workspaceInfo: IWorkspaceInfo = {
240
folder: URI.file('/workspace'),
241
repository: URI.file('/repo'),
242
worktree: URI.file('/worktree'),
243
worktreeProperties: { autoCommit: true, baseCommit: 'abc', branchName: 'test', repositoryPath: '/repo', worktreePath: '/worktree', version: 1 },
244
};
245
expect(isFileFromSessionWorkspace(URI.file('/repo/src/foo.ts'), workspaceInfo)).toBe(true);
246
});
247
248
it('returns false for file inside repository when no worktree exists', () => {
249
const workspaceInfo: IWorkspaceInfo = {
250
folder: URI.file('/workspace'),
251
repository: URI.file('/repo'),
252
worktree: undefined,
253
worktreeProperties: undefined,
254
};
255
expect(isFileFromSessionWorkspace(URI.file('/repo/src/foo.ts'), workspaceInfo)).toBe(false);
256
});
257
258
it('returns false when workspaceInfo has no folder, no repository, no worktree', () => {
259
const workspaceInfo: IWorkspaceInfo = {
260
folder: undefined,
261
repository: undefined,
262
worktree: undefined,
263
worktreeProperties: undefined,
264
};
265
expect(isFileFromSessionWorkspace(URI.file('/any/file.ts'), workspaceInfo)).toBe(false);
266
});
267
});
268
269
describe('handleReadPermission', () => {
270
let logService: ILogService;
271
let token: CancellationToken;
272
let tokenSource: CancellationTokenSource;
273
274
beforeEach(() => {
275
const services = disposables.add(createExtensionUnitTestingServices());
276
const accessor = services.createTestingAccessor();
277
logService = accessor.get(ILogService);
278
tokenSource = new CancellationTokenSource();
279
token = tokenSource.token;
280
});
281
282
afterEach(() => {
283
tokenSource.dispose();
284
});
285
286
function makeWorkspaceInfo(folder?: URI, worktree?: URI, repository?: URI): IWorkspaceInfo {
287
return {
288
folder,
289
repository,
290
worktree,
291
worktreeProperties: worktree ? { autoCommit: true, baseCommit: 'abc', branchName: 'test', repositoryPath: repository?.fsPath ?? '', worktreePath: worktree.fsPath, version: 1 } : undefined,
292
};
293
}
294
295
function makeImageSupport(trusted: boolean): ICopilotCLIImageSupport {
296
return { _serviceBrand: undefined, storeImage: vi.fn(), isTrustedImage: () => trusted };
297
}
298
299
function makeWorkspaceService(folders: URI[]): IWorkspaceService {
300
return { getWorkspaceFolder: (resource: URI) => folders.find(f => resource.fsPath.startsWith(f.fsPath)) } as unknown as IWorkspaceService;
301
}
302
303
function makeToolsService(response: string): IToolsService {
304
return {
305
invokeTool: vi.fn(async () => new LanguageModelToolResult2([new LanguageModelTextPart(response)])),
306
} as unknown as IToolsService;
307
}
308
309
it('auto-approves trusted images', async () => {
310
const req = { kind: 'read', path: '/images/cat.png' } as any;
311
const result = await handleReadPermission(
312
'session-1', req, undefined, [], makeImageSupport(true),
313
makeWorkspaceInfo(), makeWorkspaceService([]), makeToolsService('no'),
314
undefined as unknown as ChatParticipantToolToken, logService, token,
315
);
316
expect(result.kind).toBe('approve-once');
317
});
318
319
it('auto-approves files in session workspace (folder)', async () => {
320
const req = { kind: 'read', path: '/workspace/src/file.ts' } as any;
321
const result = await handleReadPermission(
322
'session-1', req, undefined, [], makeImageSupport(false),
323
makeWorkspaceInfo(URI.file('/workspace')), makeWorkspaceService([]),
324
makeToolsService('no'), undefined as unknown as ChatParticipantToolToken, logService, token,
325
);
326
expect(result.kind).toBe('approve-once');
327
});
328
329
it('auto-approves files in a VS Code workspace folder', async () => {
330
const req = { kind: 'read', path: '/vscode-ws/src/file.ts' } as any;
331
const result = await handleReadPermission(
332
'session-1', req, undefined, [], makeImageSupport(false),
333
makeWorkspaceInfo(URI.file('/other')), makeWorkspaceService([URI.file('/vscode-ws')]),
334
makeToolsService('no'), undefined as unknown as ChatParticipantToolToken, logService, token,
335
);
336
expect(result.kind).toBe('approve-once');
337
});
338
339
it('auto-approves attached files', async () => {
340
const filePath = '/external/attached.ts';
341
const req = { kind: 'read', path: filePath } as any;
342
const attachments = [{ type: 'file', path: filePath }] as any;
343
const result = await handleReadPermission(
344
'session-1', req, undefined, attachments, makeImageSupport(false),
345
makeWorkspaceInfo(), makeWorkspaceService([]),
346
makeToolsService('no'), undefined as unknown as ChatParticipantToolToken, logService, token,
347
);
348
expect(result.kind).toBe('approve-once');
349
});
350
351
it('falls back to confirmation tool for out-of-workspace reads and approves on "yes"', async () => {
352
const toolsService = makeToolsService('yes');
353
const req = { kind: 'read', path: '/external/secret.txt', intention: 'Read config' } as any;
354
const result = await handleReadPermission(
355
'session-1', req, undefined, [], makeImageSupport(false),
356
makeWorkspaceInfo(URI.file('/workspace')), makeWorkspaceService([]),
357
toolsService, undefined as unknown as ChatParticipantToolToken, logService, token,
358
);
359
expect(result.kind).toBe('approve-once');
360
expect(toolsService.invokeTool).toHaveBeenCalled();
361
});
362
363
it('denies when confirmation tool returns non-"yes"', async () => {
364
const toolsService = makeToolsService('no');
365
const req = { kind: 'read', path: '/external/secret.txt' } as any;
366
const result = await handleReadPermission(
367
'session-1', req, undefined, [], makeImageSupport(false),
368
makeWorkspaceInfo(URI.file('/workspace')), makeWorkspaceService([]),
369
toolsService, undefined as unknown as ChatParticipantToolToken, logService, token,
370
);
371
expect(result.kind).toBe('denied-interactively-by-user');
372
});
373
374
it('uses intention as message when available', async () => {
375
const toolsService = makeToolsService('yes');
376
const req = { kind: 'read', path: '/external/file.txt', intention: 'Read 3 config files' } as any;
377
await handleReadPermission(
378
'session-1', req, undefined, [], makeImageSupport(false),
379
makeWorkspaceInfo(), makeWorkspaceService([]),
380
toolsService, undefined as unknown as ChatParticipantToolToken, logService, token,
381
);
382
const callArgs = (toolsService.invokeTool as ReturnType<typeof vi.fn>).mock.calls[0];
383
expect(callArgs[0]).toBe(ToolName.CoreConfirmationTool);
384
expect(callArgs[1].input.message).toBe('Read 3 config files');
385
});
386
387
it('falls back to path when no intention', async () => {
388
const toolsService = makeToolsService('yes');
389
const req = { kind: 'read', path: '/external/file.txt' } as any;
390
await handleReadPermission(
391
'session-1', req, undefined, [], makeImageSupport(false),
392
makeWorkspaceInfo(), makeWorkspaceService([]),
393
toolsService, undefined as unknown as ChatParticipantToolToken, logService, token,
394
);
395
const callArgs = (toolsService.invokeTool as ReturnType<typeof vi.fn>).mock.calls[0];
396
expect(callArgs[1].input.message).toBe('/external/file.txt');
397
});
398
});
399
400
describe('handleWritePermission', () => {
401
let logService: ILogService;
402
let token: CancellationToken;
403
let tokenSource: CancellationTokenSource;
404
let editTracker: ExternalEditTracker;
405
406
beforeEach(() => {
407
const services = disposables.add(createExtensionUnitTestingServices());
408
const accessor = services.createTestingAccessor();
409
logService = accessor.get(ILogService);
410
tokenSource = new CancellationTokenSource();
411
token = tokenSource.token;
412
editTracker = new ExternalEditTracker();
413
editTracker.trackEdit = vi.fn(async () => { });
414
});
415
416
afterEach(() => {
417
tokenSource.dispose();
418
});
419
420
function makeWorkspaceInfo(opts: { folder?: URI; worktree?: URI; repository?: URI; worktreeProperties?: any } = {}): IWorkspaceInfo {
421
return {
422
folder: opts.folder,
423
repository: opts.repository,
424
worktree: opts.worktree,
425
worktreeProperties: opts.worktreeProperties,
426
};
427
}
428
429
function makeWorkspaceService(folders: URI[]): IWorkspaceService {
430
return { getWorkspaceFolder: (resource: URI) => folders.find(f => resource.fsPath.startsWith(f.fsPath)) } as unknown as IWorkspaceService;
431
}
432
433
function makeToolsService(response: string): IToolsService {
434
return {
435
invokeTool: vi.fn(async () => new LanguageModelToolResult2([new LanguageModelTextPart(response)])),
436
} as unknown as IToolsService;
437
}
438
439
it('auto-approves writes in workspace folder for non-protected files', async () => {
440
const wsFolder = URI.file('/workspace');
441
const req = { kind: 'write', fileName: URI.file('/workspace/src/foo.ts').fsPath, diff: '', intention: '' } as any;
442
const result = await handleWritePermission(
443
'session-1', req, undefined, undefined, undefined, editTracker,
444
makeWorkspaceInfo({ folder: wsFolder }),
445
makeWorkspaceService([wsFolder]),
446
instaService, makeToolsService('no'),
447
undefined as unknown as ChatParticipantToolToken, logService, token,
448
);
449
expect(result.kind).toBe('approve-once');
450
});
451
452
it('auto-approves writes in working directory when isolation is enabled', async () => {
453
const worktree = URI.file('/worktree');
454
const req = { kind: 'write', fileName: URI.file('/worktree/src/foo.ts').fsPath, diff: '', intention: '' } as any;
455
const result = await handleWritePermission(
456
'session-1', req, undefined, undefined, undefined, editTracker,
457
makeWorkspaceInfo({
458
folder: URI.file('/workspace'),
459
worktree,
460
worktreeProperties: { autoCommit: true, baseCommit: 'abc', branchName: 'test', repositoryPath: '/repo', worktreePath: '/worktree', version: 1 },
461
}),
462
makeWorkspaceService([]),
463
instaService, makeToolsService('no'),
464
undefined as unknown as ChatParticipantToolToken, logService, token,
465
);
466
expect(result.kind).toBe('approve-once');
467
});
468
469
it('falls back to confirmation for writes outside workspace', async () => {
470
const toolsService = makeToolsService('yes');
471
const req = { kind: 'write', fileName: URI.file('/external/foo.ts').fsPath, diff: '', intention: '' } as any;
472
const result = await handleWritePermission(
473
'session-1', req, undefined, undefined, undefined, editTracker,
474
makeWorkspaceInfo({ folder: URI.file('/workspace') }),
475
makeWorkspaceService([URI.file('/workspace')]),
476
instaService, toolsService,
477
undefined as unknown as ChatParticipantToolToken, logService, token,
478
);
479
expect(result.kind).toBe('approve-once');
480
expect(toolsService.invokeTool).toHaveBeenCalled();
481
});
482
483
it('denies writes outside workspace when user declines confirmation', async () => {
484
const toolsService = makeToolsService('no');
485
const req = { kind: 'write', fileName: URI.file('/external/foo.ts').fsPath, diff: '', intention: '' } as any;
486
const result = await handleWritePermission(
487
'session-1', req, undefined, undefined, undefined, editTracker,
488
makeWorkspaceInfo({ folder: URI.file('/workspace') }),
489
makeWorkspaceService([URI.file('/workspace')]),
490
instaService, toolsService,
491
undefined as unknown as ChatParticipantToolToken, logService, token,
492
);
493
expect(result.kind).toBe('denied-interactively-by-user');
494
});
495
496
it('auto-approves when no file can be determined (no fileName, no toolCall)', async () => {
497
const req = { kind: 'write', intention: 'some write' } as any;
498
const result = await handleWritePermission(
499
'session-1', req, undefined, undefined, undefined, editTracker,
500
makeWorkspaceInfo({ folder: URI.file('/workspace') }),
501
makeWorkspaceService([URI.file('/workspace')]),
502
instaService, makeToolsService('no'),
503
undefined as unknown as ChatParticipantToolToken, logService, token,
504
);
505
// No file => getFileEditConfirmationToolParams returns undefined => auto-approve
506
expect(result.kind).toBe('approve-once');
507
});
508
});
509
510
describe('showInteractivePermissionPrompt', () => {
511
let logService: ILogService;
512
let token: CancellationToken;
513
let tokenSource: CancellationTokenSource;
514
515
beforeEach(() => {
516
const services = disposables.add(createExtensionUnitTestingServices());
517
const accessor = services.createTestingAccessor();
518
logService = accessor.get(ILogService);
519
tokenSource = new CancellationTokenSource();
520
token = tokenSource.token;
521
});
522
523
afterEach(() => {
524
tokenSource.dispose();
525
});
526
527
function makeToolsService(response: string): IToolsService {
528
return {
529
invokeTool: vi.fn(async () => new LanguageModelToolResult2([new LanguageModelTextPart(response)])),
530
} as unknown as IToolsService;
531
}
532
533
it('approves when user confirms with "yes"', async () => {
534
const toolsService = makeToolsService('yes');
535
const req = { kind: 'url', url: 'https://example.com' } as any;
536
const result = await showInteractivePermissionPrompt(
537
req, undefined, toolsService,
538
undefined as unknown as ChatParticipantToolToken, logService, token,
539
);
540
expect(result.kind).toBe('approve-once');
541
const callArgs = (toolsService.invokeTool as ReturnType<typeof vi.fn>).mock.calls[0];
542
expect(callArgs[0]).toBe(ToolName.CoreConfirmationTool);
543
expect(callArgs[1].input.title).toBe('Copilot CLI Permission Request');
544
});
545
546
it('denies when user declines', async () => {
547
const toolsService = makeToolsService('no');
548
const req = { kind: 'url', url: 'https://example.com' } as any;
549
const result = await showInteractivePermissionPrompt(
550
req, undefined, toolsService,
551
undefined as unknown as ChatParticipantToolToken, logService, token,
552
);
553
expect(result.kind).toBe('denied-interactively-by-user');
554
});
555
556
it('denies when invokeTool throws', async () => {
557
const toolsService = {
558
invokeTool: vi.fn(async () => { throw new Error('tool failure'); }),
559
} as unknown as IToolsService;
560
const req = { kind: 'url', url: 'https://example.com' } as any;
561
const result = await showInteractivePermissionPrompt(
562
req, undefined, toolsService,
563
undefined as unknown as ChatParticipantToolToken, logService, token,
564
);
565
expect(result.kind).toBe('denied-interactively-by-user');
566
});
567
568
it('passes toolParentCallId as subAgentInvocationId', async () => {
569
const toolsService = makeToolsService('yes');
570
const req = { kind: 'url', url: 'https://example.com' } as any;
571
await showInteractivePermissionPrompt(
572
req, 'parent-123', toolsService,
573
undefined as unknown as ChatParticipantToolToken, logService, token,
574
);
575
const callArgs = (toolsService.invokeTool as ReturnType<typeof vi.fn>).mock.calls[0];
576
expect(callArgs[1].subAgentInvocationId).toBe('parent-123');
577
});
578
579
it('approves with case-insensitive "Yes"', async () => {
580
const toolsService = makeToolsService('Yes');
581
const req = { kind: 'url', url: 'https://example.com' } as any;
582
const result = await showInteractivePermissionPrompt(
583
req, undefined, toolsService,
584
undefined as unknown as ChatParticipantToolToken, logService, token,
585
);
586
expect(result.kind).toBe('approve-once');
587
});
588
});
589
});
590
591