src/vs/platform/agentHost/node/copilot/copilotShellTools.ts

811 LOC · 662 covered · 149 uncovered · 110 ranges · 966 concepts · 40 introducers · 453 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- copilotShellTools.ts ×23
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 type { Tool, ToolResultObject } from '@github/copilot-sdk';
7 > import { generateUuid } from '../../../../base/common/uuid.js';
8 > import { URI } from '../../../../base/common/uri.js';
9 > import { Disposable, DisposableStore, type IReference, toDisposable } from '../../../../base/common/lifecycle.js';
10 > import { Emitter, Event } from '../../../../base/common/event.js';
11 > import { IEnvironmentService } from '../../../environment/common/environment.js';
12 > import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
13 > import { ILogService } from '../../../log/common/log.js';
14 > import { IProductService } from '../../../product/common/productService.js';
15 > import { ISandboxHelperService } from '../../../sandbox/common/sandboxHelperService.js';
16 > import type { ITerminalSandboxResolvedNetworkDomains } from '../../../sandbox/common/terminalSandboxService.js';
17 > import { TerminalSandboxEngine } from '../../../sandbox/common/terminalSandboxEngine.js';
18 > import { TerminalClaimKind, type TerminalSessionClaim } from '../../common/state/protocol/state.js';
19 > import { isZsh } from '../agentHostShellUtils.js';
20 > import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js';
21 > import { createAgentHostSandboxEngine } from './agentHostSandboxEngine.js';
22 > import { IAgentConfigurationService } from '../agentConfigurationService.js';
23 > import { DEFAULT_SHELL_COMMAND_TIMEOUT_MS, executeShellCommand, isMultilineCommand, prefixForHistorySuppression, prepareOutputForModel, shellTypeForExecutable, type IShellCommandResult, type ShellType } from '../shared/shellCommandExecution.js';
24 >
25 > // Re-exported for consumers (and tests) that historically imported these
26 > // shell helpers from this module. Their canonical home is the shared,
27 > // agent-agnostic shellCommandExecution module.
28 > export { isMultilineCommand, prefixForHistorySuppression, shellTypeForExecutable };
29 > export type { ShellType };
30 >
31 > /**
32 > * Message returned to the model when a command switches to the terminal's
33 > * alternate buffer (typically an interactive full-screen UI).
34 > */
35 > const ALT_BUFFER_MESSAGE = 'The command opened the alternate buffer and is still running in the terminal. It likely launched an interactive terminal UI. Use write_bash/write_powershell to interact with it, or shutdown the shell to stop it.';
36 >
37 > /**
38 > * Tracks a single persistent shell instance backed by a managed PTY terminal.
39 > */
40 > interface IManagedShell {
41 > readonly id: string;
42 > readonly terminalUri: string;
43 > readonly shellType: ShellType;
44 > readonly executable: string;
45 > }
46 >
47 > // ---------------------------------------------------------------------------
48 > // ShellManager
49 > // ---------------------------------------------------------------------------
50 >
51 > /**
52 > * Per-session manager for persistent shell instances. Each shell is backed by
53 > * a {@link IAgentHostTerminalManager} terminal and participates in AHP terminal
54 > * claim semantics.
55 > *
56 > * Created via {@link IInstantiationService} once per session and disposed when
57 > * the session ends.
58 > */
59 > export class ShellManager extends Disposable {
60 >
61 > private readonly _shells = new Map<string, IManagedShell>();
62 > private readonly _toolCallShells = new Map<string, string>();
63 > private _resolvedExecutable: Promise<string> | undefined;
64 > private _sandboxEngine: TerminalSandboxEngine | undefined;
65 > /** Set of shell ids currently executing a command and unsafe to share. */
66 > private readonly _busyShellIds = new Set<string>();
67 > /** Release listeners for shells held after a tool returns while the command is still running. */
68 > private readonly _heldShellReleaseListeners = new Map<string, DisposableStore>();
69 >
70 > private readonly _onDidAssociateTerminal = this._register(new Emitter<{ toolCallId: string; terminalUri: string; displayName: string }>());
71 > readonly onDidAssociateTerminal: Event<{ toolCallId: string; terminalUri: string; displayName: string }> = this._onDidAssociateTerminal.event;
72 >
73 > constructor(
74 > private readonly _sessionUri: URI, copilotShellTools.ts ×3
75 > public readonly workingDirectory: URI | undefined,
76 > @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager,
77 > @ILogService private readonly _logService: ILogService,
78 > @IInstantiationService private readonly _instantiationService: IInstantiationService,
79 > @IEnvironmentService private readonly _environmentService: IEnvironmentService,
80 > @IProductService private readonly _productService: IProductService,
81 > @IAgentConfigurationService private readonly _agentConfigurationService: IAgentConfigurationService,
82 > @ISandboxHelperService private readonly _sandboxHelper: ISandboxHelperService,
83 > ) {
84 > super();
85 >
86 > this._register(toDisposable(() => {
87 > for (const store of this._heldShellReleaseListeners.values()) {
88 > store.dispose(); copilotShellTools.ts ×1
89 > }
90 > this._heldShellReleaseListeners.clear(); copilotShellTools.ts ×3
91 > for (const shell of this._shells.values()) {
92 > if (this._terminalManager.hasTerminal(shell.terminalUri)) { copilotShellTools.ts ×7
93 > this._terminalManager.disposeTerminal(shell.terminalUri); copilotShellTools.ts ×1
94 > }
96 > this._shells.clear(); copilotShellTools.ts ×3
97 > this._toolCallShells.clear();
98 > this._busyShellIds.clear();
99 > }));
100 > }
102 > /**
103 > * Resolves the session's shell executable via {@link IAgentHostTerminalManager.getDefaultShell}
104 > * and caches it so every tool call in the session uses the same binary
105 > * (keeps `shellType`, sentinel format, and history suppression consistent).
106 > */
107 > getResolvedExecutable(): Promise<string> {
108 > if (!this._resolvedExecutable) { copilotShellTools.ts ×1
109 > this._resolvedExecutable = this._terminalManager.getDefaultShell();
110 > }
111 > return this._resolvedExecutable;
112 > }
114 > /**
115 > * Lazily constructs the per-session {@link TerminalSandboxEngine}. The engine
116 > * is registered for disposal alongside the {@link ShellManager}; its temp dir
117 > * is cleaned up best-effort on dispose.
118 > */
119 > getOrCreateSandboxEngine(): TerminalSandboxEngine {
120 > if (!this._sandboxEngine) { agentHostSandboxEngine.ts ×2
121 > const sessionId = this._sessionUri.path.split('/').pop() ?? generateUuid();
122 > const engine = createAgentHostSandboxEngine(
123 > this._instantiationService,
124 > this._environmentService,
125 > this._productService,
126 > this._agentConfigurationService,
127 > this._sandboxHelper,
128 > sessionId,
129 > this.workingDirectory,
130 > );
131 > this._register(engine);
132 > this._register(toDisposable(() => {
133 > void engine.cleanupTempDir().catch(err => this._logService.warn('[ShellManager] Sandbox temp dir cleanup failed', err));
134 > }));
135 > this._sandboxEngine = engine;
136 > }
137 > return this._sandboxEngine;
138 > }
140 > /**
141 > * Acquire a shell of the given type for executing a single command. The
142 > * returned reference holds the shell exclusively — its terminal will not
143 > * be handed out to another concurrent caller until the reference is
144 > * disposed. If no idle shell of the requested type exists, a new one is
145 > * created.
146 > */
147 > async getOrCreateShell(
148 > shellType: ShellType, copilotShellTools.ts ×7
149 > turnId: string,
150 > toolCallId: string,
151 > cwd?: string,
152 > ): Promise<IReference<IManagedShell>> {
153 > for (const shell of this._shells.values()) {
154 > if (shell.shellType !== shellType || !this._terminalManager.hasTerminal(shell.terminalUri)) { copilotShellTools.ts ×1
155 > continue; copilotShellTools.ts ×1
156 > }
157 > const exitCode = this._terminalManager.getExitCode(shell.terminalUri); copilotShellTools.ts ×2
158 > if (exitCode !== undefined) {
159 this._shells.delete(shell.id);
160 continue;
161 }
162 > if (this._busyShellIds.has(shell.id)) { copilotShellTools.ts ×2
163 > // Skip — a command is already running on this terminal. Sharing copilotShellTools.ts ×1
164 > // it would interleave input/output and garble both commands.
165 > continue;
166 > }
167 > this._busyShellIds.add(shell.id); copilotShellTools.ts ×1
168 > this._trackToolCall(toolCallId, shell.id);
169 > return this._makeReference(shell);
170 > }
172 > const id = generateUuid();
173 > const terminalUri = `agenthost-terminal://shell/${id}`;
174 >
175 > const claim: TerminalSessionClaim = {
176 > kind: TerminalClaimKind.Session,
177 > session: this._sessionUri.toString(),
178 > turnId,
179 > toolCallId,
180 > };
181 >
182 > const shellDisplayName = shellType === 'bash' ? 'Bash' : 'PowerShell';
183 > const executable = await this.getResolvedExecutable();
184 >
185 > await this._terminalManager.createTerminal({
186 > channel: terminalUri,
187 > claim,
188 > name: shellDisplayName,
189 > cwd: cwd ?? this.workingDirectory?.fsPath,
190 > }, { shell: executable, preventShellHistory: true, nonInteractive: true });
191 >
192 > const shell: IManagedShell = { id, terminalUri, shellType, executable };
193 > this._shells.set(id, shell);
194 > this._busyShellIds.add(id);
195 > this._trackToolCall(toolCallId, id);
196 >
197 > this._logService.info(`[ShellManager] Created ${shellType} shell ${id} (terminal=${terminalUri}, executable=${executable})`);
198 > return this._makeReference(shell);
199 > }
201 > private _makeReference(shell: IManagedShell): IReference<IManagedShell> {
202 > let disposed = false; copilotShellTools.ts ×7
203 > return {
204 > object: shell,
205 > dispose: () => {
206 > if (disposed) { copilotShellTools.ts ×2
207 return;
208 }
209 > disposed = true; copilotShellTools.ts ×2
210 > this._busyShellIds.delete(shell.id);
211 > },
213 > }
215 > holdShellUntilCommandFinishes(shell: IManagedShell): void {
216 > if (this._heldShellReleaseListeners.has(shell.id)) { copilotShellTools.ts ×4
217 return;
218 }
220 > const store = new DisposableStore();
221 > const release = () => {
222 > this._busyShellIds.delete(shell.id); copilotShellTools.ts ×1
223 > this._heldShellReleaseListeners.delete(shell.id);
224 > store.dispose();
225 > };
226 > store.add(this._terminalManager.onCommandFinished(shell.terminalUri, release)); copilotShellTools.ts ×4
227 > store.add(this._terminalManager.onExit(shell.terminalUri, release));
228 > this._heldShellReleaseListeners.set(shell.id, store);
229 > }
231 > private _trackToolCall(toolCallId: string, shellId: string): void {
232 > this._toolCallShells.set(toolCallId, shellId); copilotShellTools.ts ×7
233 > const shell = this._shells.get(shellId);
234 > if (shell) {
235 > const displayName = shell.shellType === 'bash' ? 'Bash' : 'PowerShell';
236 > this._onDidAssociateTerminal.fire({ toolCallId, terminalUri: shell.terminalUri, displayName });
237 > }
238 > }
240 > getTerminalUriForToolCall(toolCallId: string): string | undefined {
241 const shellId = this._toolCallShells.get(toolCallId);
242 if (!shellId) {
243 return undefined;
244 }
245 return this._shells.get(shellId)?.terminalUri;
246 }
248 > getShell(id: string): IManagedShell | undefined {
249 return this._shells.get(id);
250 }
252 > listShells(): IManagedShell[] {
253 > const result: IManagedShell[] = []; copilotShellTools.ts ×1
254 > for (const shell of this._shells.values()) {
255 > if (this._terminalManager.hasTerminal(shell.terminalUri)) {
256 > result.push(shell);
257 > }
258 > }
259 > return result;
260 > }
262 > shutdownShell(id: string): boolean {
263 const shell = this._shells.get(id);
264 if (!shell) {
265 return false;
266 }
267 this._heldShellReleaseListeners.get(id)?.dispose();
268 this._heldShellReleaseListeners.delete(id);
269 this._terminalManager.disposeTerminal(shell.terminalUri);
270 this._shells.delete(id);
271 this._busyShellIds.delete(id);
272 this._logService.info(`[ShellManager] Shut down shell ${id}`);
273 return true;
274 }
276 >
277 > // ---------------------------------------------------------------------------
278 > // Tool implementations
279 > // ---------------------------------------------------------------------------
280 >
281 > interface IShellExecutionResult {
282 > readonly toolResult: ToolResultObject;
283 > readonly keepShellBusy?: boolean;
284 > }
285 >
286 > function makeSuccessResult(text: string): ToolResultObject { copilotShellTools.ts ×1
287 > return { textResultForLlm: text, resultType: 'success' };
288 > }
290 > function makeFailureResult(text: string, error?: string): ToolResultObject { copilotShellTools.ts ×1
291 > return { textResultForLlm: text, resultType: 'failure', error };
292 > }
294 > function makeExecutionResult(toolResult: ToolResultObject, options?: { keepShellBusy?: boolean }): IShellExecutionResult { copilotShellTools.ts ×10
295 > return { toolResult, keepShellBusy: options?.keepShellBusy };
296 > }
298 > /**
299 > * Maps the neutral {@link IShellCommandResult} produced by the shared shell
300 > * executor to the Copilot SDK {@link ToolResultObject} shape expected by the
301 > * shell tools.
302 > */
303 > function shellCommandResultToExecutionResult(result: IShellCommandResult, timeoutMs: number): IShellExecutionResult { copilotShellTools.ts ×10
304 > switch (result.status) {
305 > case 'completed': {
306 > const exitCode = result.exitCode ?? 0; copilotShellTools.ts ×1
307 > const text = `Exit code: ${exitCode}\n${result.output}`;
308 > return makeExecutionResult(exitCode === 0 ? makeSuccessResult(text) : makeFailureResult(text));
309 > }
310 > case 'shellExited': copilotShellTools.ts ×10
311 return makeExecutionResult(makeFailureResult(`Shell exited with code ${result.exitCode}\n${result.output}`));
312 > case 'timeout': copilotShellTools.ts ×10
313 > return makeExecutionResult(makeFailureResult( copilotShellTools.ts ×1
314 > `Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${result.output}`,
315 > 'timeout',
316 > ));
317 > case 'background': copilotShellTools.ts ×10
318 > return makeExecutionResult( copilotShellTools.ts ×1
319 > makeSuccessResult('The user chose to continue this command in the background. The terminal is still running.'),
320 > { keepShellBusy: true },
321 > );
322 > case 'altBuffer': copilotShellTools.ts ×10
323 > return makeExecutionResult(makeFailureResult(ALT_BUFFER_MESSAGE, 'alternateBuffer'), { keepShellBusy: true }); copilotShellTools.ts ×1
325 > }
327 > async function executeCommandInShell( copilotShellTools.ts ×10
328 > shell: IManagedShell,
329 > command: string,
330 > timeoutMs: number,
331 > terminalManager: IAgentHostTerminalManager,
332 > logService: ILogService,
333 > ): Promise<IShellExecutionResult> {
334 > const result = shellCommandResultToExecutionResult(
335 > await executeShellCommand(shell, command, timeoutMs, terminalManager, logService),
336 > timeoutMs,
337 > );
338 > return {
339 > ...result,
340 > toolResult: {
341 > ...result.toolResult,
342 > textResultForLlm: `Shell ID: ${shell.id}\n${result.toolResult.textResultForLlm}`,
343 > },
344 > };
345 > }
347 > // ---------------------------------------------------------------------------
348 > // Public factory
349 > // ---------------------------------------------------------------------------
350 >
351 > interface IShellToolArgs {
352 > command: string;
353 > timeout?: number;
354 > requestUnsandboxedExecution?: boolean;
355 > requestUnsandboxedExecutionReason?: string;
356 > }
357 >
358 > export interface IUnsandboxedCommandConfirmationRequest {
359 > readonly toolCallId: string;
360 > readonly toolName: string;
361 > readonly shellExecutable: string;
362 > readonly command: string;
363 > readonly reason?: string;
364 > readonly blockedDomains?: readonly string[];
365 > }
366 >
367 > export type UnsandboxedCommandConfirmationHandler = (request: IUnsandboxedCommandConfirmationRequest) => Promise<boolean>;
368 >
369 > interface IWriteShellArgs {
370 > command: string;
371 > }
372 >
373 > interface IReadShellArgs {
374 > shell_id?: string;
375 > }
376 >
377 > interface IShutdownShellArgs {
378 > shell_id?: string;
379 > }
380 >
381 > /**
382 > * Builds the SDK {@link Tool} set that overrides the Copilot SDK's two
383 > * built-in shells (`bash` and `powershell`) with PTY-backed implementations,
384 > * plus companion tools (read, write, shutdown, list).
385 > */
386 > export async function createShellTools( copilotShellTools.ts ×11
387 > shellManager: ShellManager,
388 > terminalManager: IAgentHostTerminalManager,
389 > logService: ILogService,
390 > confirmUnsandboxedExecution?: UnsandboxedCommandConfirmationHandler,
391 > // eslint-disable-next-line @typescript-eslint/no-explicit-any
392 > ): Promise<Tool<any>[]> {
393 > const executable = await shellManager.getResolvedExecutable();
394 > const shellType = shellTypeForExecutable(executable);
395 > const engine = shellManager.getOrCreateSandboxEngine();
396 > const sandboxEnabled = await engine.isEnabled();
397 > const networkDomains = sandboxEnabled ? engine.getResolvedNetworkDomains() : undefined;
398 >
399 > const primaryTool: Tool<IShellToolArgs> = {
400 > name: shellType,
401 > description: shellType === 'bash'
402 > ? (isZsh(executable) ? createZshModelDescription(sandboxEnabled, networkDomains) : createBashModelDescription(sandboxEnabled, networkDomains))
403 : createPowerShellModelDescription(shellType, executable, sandboxEnabled, networkDomains),
404 > parameters: { copilotShellTools.ts ×11
405 > type: 'object',
406 > properties: {
407 > command: { type: 'string', description: 'The command to execute' },
408 > timeout: { type: 'number', description: 'Timeout in milliseconds (default 120000)' },
409 > ...(sandboxEnabled ? {
410 > requestUnsandboxedExecution: { copilotShellTools.ts ×5
411 > type: 'boolean',
412 > description: 'Request that this command run outside the sandbox. Only set this after first executing the command in the sandbox and observing that sandboxing caused the failure. The user will be prompted before the command runs unsandboxed.',
413 > },
414 > requestUnsandboxedExecutionReason: {
415 > type: 'string',
416 > description: 'A short explanation of the sandboxed execution failure or blocked-domain requirement that justifies retrying outside the sandbox. Only provide this when requestUnsandboxedExecution is true.',
417 > },
418 > } : {}), copilotShellTools.ts ×11
419 > },
420 > required: ['command'],
421 > },
422 > overridesBuiltInTool: true,
423 > handler: async (args, invocation) => {
424 > const timeoutMs = args.timeout ?? DEFAULT_SHELL_COMMAND_TIMEOUT_MS; copilotShellTools.ts ×3
425 > const ref = await shellManager.getOrCreateShell(
426 > shellType,
427 > invocation.toolCallId,
428 > invocation.toolCallId,
429 > );
430 > let shouldReleaseShell = true;
431 > try {
432 > let commandToRun = args.command;
433 > if (sandboxEnabled) {
434 > if (args.requestUnsandboxedExecution && !engine.areUnsandboxedCommandsAllowed()) { copilotShellTools.ts ×3
435 > return makeFailureResult( copilotShellTools.ts ×1
436 > 'Unsandboxed execution is disabled by the chat.agent.sandbox.allowUnsandboxedCommands setting.',
437 > 'unsandboxed_disabled'
438 > );
439 > }
441 > const requestUnsandboxedConfirmation = async (blockedDomains?: readonly string[]): Promise<boolean | ToolResultObject> => {
442 > if (!confirmUnsandboxedExecution) { terminalSandboxEngine.ts ×4
443 const blocked = blockedDomains?.join(', ') ?? '(unknown)';
444 return makeFailureResult(
445 `Command requires approval to run outside the sandbox. Blocked domains: ${blocked}. Re-run with requestUnsandboxedExecution=true and requestUnsandboxedExecutionReason explaining why unsandboxed access is required.`,
446 'sandbox_blocked'
447 );
448 }
450 > const approved = await confirmUnsandboxedExecution({
451 > toolCallId: invocation.toolCallId,
452 > toolName: invocation.toolName,
453 > shellExecutable: executable,
454 > command: args.command,
455 > reason: args.requestUnsandboxedExecutionReason,
456 > blockedDomains,
457 > });
458 > return approved;
459 > };
461 > let wrapped = await engine.wrapCommand(
462 > args.command,
463 > args.requestUnsandboxedExecution,
464 > executable,
465 > ref.object.shellType === 'bash' ? shellManager.workingDirectory : undefined, copilotShellTools.ts ×3
466 > );
468 > if (args.requestUnsandboxedExecution && !wrapped.isSandboxWrapped) { copilotShellTools.ts ×3
469 > const decision = await requestUnsandboxedConfirmation(wrapped.blockedDomains); copilotShellTools.ts ×2
470 > if (typeof decision !== 'boolean') {
471 return decision;
472 }
473 > if (!decision) { copilotShellTools.ts ×2
474 > const blocked = wrapped.blockedDomains?.join(', ') ?? '(none)';
475 > return makeFailureResult(
476 > `User declined to run command outside the sandbox. Blocked domains: ${blocked}.`,
477 > 'sandbox_blocked'
478 > );
479 > }
480 > }
482 > if (wrapped.requiresUnsandboxConfirmation) {
483 > const decision = await requestUnsandboxedConfirmation(wrapped.blockedDomains); copilotShellTools.ts ×3
484 > if (typeof decision !== 'boolean') {
485 return decision;
486 }
487 > if (!decision) { copilotShellTools.ts ×3
488 > const blocked = wrapped.blockedDomains?.join(', ') ?? '(unknown)'; copilotShellTools.ts ×1
489 > return makeFailureResult(
490 > `User declined to run command outside the sandbox. Blocked domains: ${blocked}.`,
491 > 'sandbox_blocked'
492 > );
493 > }
495 > wrapped = await engine.wrapCommand(
496 > args.command,
497 > true,
498 > executable,
499 > ref.object.shellType === 'bash' ? shellManager.workingDirectory : undefined, copilotShellTools.ts ×3
500 > );
502 > commandToRun = wrapped.command; copilotShellTools.ts ×1
503 > }
504 > const result = await executeCommandInShell(ref.object, commandToRun, timeoutMs, terminalManager, logService); copilotShellTools.ts ×10
505 > if (result.keepShellBusy) {
506 > shouldReleaseShell = false; copilotShellTools.ts ×4
507 > shellManager.holdShellUntilCommandFinishes(ref.object);
508 > }
509 > return result.toolResult; copilotShellTools.ts ×10
510 > } finally { copilotShellTools.ts ×3
511 > if (shouldReleaseShell) {
512 > ref.dispose(); copilotShellTools.ts ×1
513 > }
515 > },
517 >
518 > const readTool: Tool<IReadShellArgs> = {
519 > name: `read_${shellType}`,
520 > description: `Read the latest output from a running ${shellType} shell.`,
521 > parameters: {
522 > type: 'object',
523 > properties: {
524 > shell_id: { type: 'string', description: 'Shell ID to read from (optional; uses latest shell if omitted)' },
525 > },
526 > },
527 > overridesBuiltInTool: true,
528 > skipPermission: true,
529 > handler: (args) => {
530 const shells = shellManager.listShells();
531 const shell = args.shell_id
532 ? shellManager.getShell(args.shell_id)
533 : shells[shells.length - 1];
534 if (!shell) {
535 return makeFailureResult('No active shell found.', 'no_shell');
536 }
537 const content = terminalManager.getContent(shell.terminalUri);
538 if (!content) {
539 return makeSuccessResult('(no output)');
540 }
541 return makeSuccessResult(prepareOutputForModel(content));
542 },
544 >
545 > const writeTool: Tool<IWriteShellArgs> = {
546 > name: `write_${shellType}`,
547 > description: `Send input to a running ${shellType} shell (e.g. answering a prompt, sending Ctrl+C).`,
548 > parameters: {
549 > type: 'object',
550 > properties: {
551 > command: { type: 'string', description: 'Text to write to the shell stdin' },
552 > },
553 > required: ['command'],
554 > },
555 > overridesBuiltInTool: true,
556 > skipPermission: true,
557 > handler: async (args) => {
558 > const shells = shellManager.listShells(); copilotShellTools.ts ×2
559 > const shell = shells[shells.length - 1];
560 > if (!shell) {
561 return makeFailureResult('No active shell found.', 'no_shell');
562 }
563 > await terminalManager.sendText(shell.terminalUri, args.command, { shouldExecute: false }); copilotShellTools.ts ×2
564 > return makeSuccessResult('Input sent to shell.');
565 > },
567 >
568 > const shutdownTool: Tool<IShutdownShellArgs> = {
569 > name: shellType === 'bash' ? 'bash_shutdown' : `${shellType}_shutdown`,
570 > description: `Stop a ${shellType} shell.`,
571 > parameters: {
572 > type: 'object',
573 > properties: {
574 > shell_id: { type: 'string', description: 'Shell ID to stop (optional; stops latest shell if omitted)' },
575 > },
576 > },
577 > overridesBuiltInTool: true,
578 > skipPermission: true,
579 > handler: (args) => {
580 if (args.shell_id) {
581 const success = shellManager.shutdownShell(args.shell_id);
582 return success
583 ? makeSuccessResult('Shell stopped.')
584 : makeFailureResult('Shell not found.', 'not_found');
585 }
586 const shells = shellManager.listShells();
587 const shell = shells[shells.length - 1];
588 if (!shell) {
589 return makeFailureResult('No active shell to stop.', 'no_shell');
590 }
591 shellManager.shutdownShell(shell.id);
592 return makeSuccessResult('Shell stopped.');
593 },
595 >
596 > const listTool: Tool<Record<string, never>> = {
597 > name: `list_${shellType}`,
598 > description: `List active ${shellType} shell instances.`,
599 > parameters: { type: 'object', properties: {} },
600 > overridesBuiltInTool: true,
601 > skipPermission: true,
602 > handler: () => {
603 const shells = shellManager.listShells();
604 if (shells.length === 0) {
605 return makeSuccessResult('No active shells.');
606 }
607 const descriptions = shells.map(s => {
608 const exitCode = terminalManager.getExitCode(s.terminalUri);
609 const status = exitCode !== undefined ? `exited (${exitCode})` : 'running';
610 return `- ${s.id}: ${s.shellType} [${status}]`;
611 });
612 return makeSuccessResult(descriptions.join('\n'));
613 },
615 >
616 > // Stub the *other* SDK built-in so the model can't bypass our override
617 > // (e.g. on Windows still calling `powershell` when Git Bash is configured).
618 > const otherShellType: ShellType = shellType === 'bash' ? 'powershell' : 'bash';
619 > const redirectMessage = `This tool is disabled because the configured shell is ${executable}. Use the \`${shellType}\` tool instead.`;
620 > const redirectTool: Tool<IShellToolArgs> = {
621 > name: otherShellType,
622 > description: redirectMessage,
623 > parameters: {
624 > type: 'object',
625 > properties: {
626 > command: { type: 'string', description: 'The command to execute' },
627 > timeout: { type: 'number', description: 'Timeout in milliseconds (default 120000)' },
628 > },
629 > required: ['command'],
630 > },
631 > overridesBuiltInTool: true,
632 > skipPermission: true,
633 > handler: () => {
634 return makeFailureResult(redirectMessage, 'wrong_shell');
635 },
637 >
638 > return [primaryTool, readTool, writeTool, shutdownTool, listTool, redirectTool];
639 > }
641 function isWindowsPowerShell(envShell: string): boolean {
642 return envShell.endsWith('System32\\WindowsPowerShell\\v1.0\\powershell.exe');
643 }
645 function createPowerShellModelDescription(shellType: string, shellPath: string, isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string {
646 const isWinPwsh = isWindowsPowerShell(shellPath);
647 const parts = [
648 `This tool allows you to execute ${isWinPwsh ? 'Windows PowerShell 5.1' : 'PowerShell'} commands in a persistent terminal session, preserving environment variables, working directory, and other context across multiple commands.`,
649 '',
650 'Command Execution:',
651 // IMPORTANT: PowerShell 5 does not support `&&` so always re-write them to `;`. Note that
652 // the behavior of `&&` differs a little from `;` but in general it's fine
653 isWinPwsh ? '- Use semicolons ; to chain commands on one line, NEVER use && even when asked explicitly' : '- Prefer ; when chaining commands on one line',
654 '- Prefer pipelines | for object-based data flow',
655 '- Never create a sub-shell (eg. powershell -c "command") unless explicitly asked',
656 '',
657 'Directory Management:',
658 '- Prefer relative paths when navigating directories, only use absolute when the path is far away or the current cwd is not expected',
659 '- By default (mode=sync), shell and cwd are reused by subsequent sync commands',
660 '- Use $PWD or Get-Location for current directory',
661 '- Use Push-Location/Pop-Location for directory stack',
662 '',
663 'Program Execution:',
664 '- Supports .NET, Python, Node.js, and other executables',
665 '- Install modules via Install-Module, Install-Package',
666 '- Use Get-Command to verify cmdlet/function availability',
667 '',
668 'Async Mode:',
669 '- For long-running tasks (e.g., servers), use mode=async',
670 '- Returns a terminal ID for checking status and runtime later',
671 '- Use Start-Job for background PowerShell jobs',
672 '',
673 `Use write_${shellType} to send commands or input to a terminal session.`,
674 ];
675
676 if (isSandboxEnabled) {
677 parts.push(...createSandboxLines(networkDomains));
678 }
679
680 parts.push(
681 '',
682 'Output Management:',
683 '- Output is automatically truncated if longer than 60KB to prevent context overflow',
684 '- Use Select-Object, Where-Object, Format-Table to filter output',
685 '- Use -First/-Last parameters to limit results',
686 '- For pager commands, add | Out-String or | Format-List',
687 '',
688 'Best Practices:',
689 '- Use proper cmdlet names instead of aliases in scripts',
690 '- Quote paths with spaces: "C:\\Path With Spaces"',
691 '- Prefer PowerShell cmdlets over external commands when available',
692 '- Prefer idiomatic PowerShell like Get-ChildItem instead of dir or ls for file listings',
693 '- Use Test-Path to check file/directory existence',
694 '- Be specific with Select-Object properties to avoid excessive output',
695 '- Avoid printing credentials unless absolutely required',
696 '',
697 'Interactive Input Handling:',
698 '- When a terminal command is waiting for interactive input, do NOT suggest alternatives or ask the user whether to proceed. Instead, use the ask_user tool to collect the needed values from the user, then send them.',
699 `- Send exactly one answer per prompt using write_${shellType}. Never send multiple answers in a single send.`,
700 `- After each send, call read_${shellType} to read the next prompt before sending the next answer.`,
701 '- Continue one prompt at a time until the command finishes.',
702 );
703
704 return parts.join('\n');
705 }
707 > function createSandboxLines(networkDomains?: ITerminalSandboxResolvedNetworkDomains): string[] { copilotShellTools.ts ×5
708 > const lines = [
709 > '',
710 > 'Sandboxing:',
711 > '- ATTENTION: Terminal sandboxing is enabled, commands run in a sandbox by default',
712 > '- When executing commands within the sandboxed environment, all operations requiring a temporary directory must utilize the $TMPDIR environment variable. The /tmp directory is not guaranteed to be accessible or writable and must be avoided',
713 > '- Tools and scripts should respect the TMPDIR environment variable, which is automatically set to an appropriate path within the sandbox',
714 > '- When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true. Do NOT ask the user for permission — setting this flag automatically shows a confirmation prompt to the user',
715 > '- Only set requestUnsandboxedExecution=true when there is evidence of failures caused by the sandbox, e.g. \'Operation not permitted\' errors, network failures, or file access errors, etc',
716 > '- Do NOT set requestUnsandboxedExecution=true without first executing the command in sandbox mode. Always try the command in the sandbox first, and only set requestUnsandboxedExecution=true when retrying after that sandboxed execution failed due to sandbox restrictions.',
717 > '- When setting requestUnsandboxedExecution=true, also provide requestUnsandboxedExecutionReason explaining why the command needs unsandboxed access',
718 > ];
719 > if (networkDomains) {
720 > const deniedSet = new Set(networkDomains.deniedDomains);
721 > const effectiveAllowed = networkDomains.allowedDomains.filter(d => !deniedSet.has(d));
722 > if (effectiveAllowed.length === 0) {
723 > lines.push('- All network access is blocked in the sandbox');
724 > } else {
725 lines.push(`- Only the following domains are accessible in the sandbox (all other network access is blocked): ${effectiveAllowed.join(', ')}`);
726 }
727 > if (networkDomains.deniedDomains.length > 0) { copilotShellTools.ts ×5
728 lines.push(`- The following domains are explicitly blocked in the sandbox: ${networkDomains.deniedDomains.join(', ')}`);
729 }
731 > return lines;
732 > }
734 > function createGenericDescription(shellType: string, isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { copilotShellTools.ts ×11
735 > const parts = [`
736 > Command Execution:
737 > - Use && to chain simple commands on one line
738 > - Prefer pipelines | over temporary files for data flow
739 > - Never create a sub-shell (eg. bash -c "command") unless explicitly asked
740 >
741 > Directory Management:
742 > - Prefer relative paths when navigating directories, only use absolute when the path is far away or the current cwd is not expected
743 > - By default (mode=sync), shell and cwd are reused by subsequent sync commands
744 > - Use $PWD for current directory references
745 > - Consider using pushd/popd for directory stack management
746 > - Supports directory shortcuts like ~ and -
747 >
748 > Program Execution:
749 > - Supports Python, Node.js, and other executables
750 > - Install packages via package managers (brew, apt, etc.)
751 > - Use which or command -v to verify command availability
752 >
753 > Async Mode:
754 > - For long-running tasks (e.g., servers), use mode=async
755 > - Returns a terminal ID for checking status and runtime later
756 >
757 > Use write_${shellType} to send commands or input to a terminal session.`];
758 >
759 > if (isSandboxEnabled) {
760 > parts.push(createSandboxLines(networkDomains).join('\n')); copilotShellTools.ts ×5
761 > }
763 > parts.push(`
764 >
765 > Output Management:
766 > - Output is automatically truncated if longer than 60KB to prevent context overflow
767 > - Use head, tail, grep, awk to filter and limit output size
768 > - For pager commands, disable paging: git --no-pager or add | cat
769 > - Use wc -l to count lines before displaying large outputs
770 >
771 > Best Practices:
772 > - Quote variables: "$var" instead of $var to handle spaces
773 > - Use find with -exec or xargs for file operations
774 > - Be specific with commands to avoid excessive output
775 > - Avoid printing credentials unless absolutely required
776 > - NEVER run sleep or similar wait commands in a terminal. You will be automatically notified on your next turn when async terminal commands or timed-out sync commands complete or need input. Do NOT poll for completion.
777 >
778 > Interactive Input Handling:
779 > - When a terminal command is waiting for interactive input, do NOT suggest alternatives or ask the user whether to proceed. Instead, use the ask_user tool to collect the needed values from the user, then send them.
780 > - Send exactly one answer per prompt using write_${shellType}. Never send multiple answers in a single send.
781 > - After each send, call read_${shellType} to read the next prompt before sending the next answer.
782 > - Continue one prompt at a time until the command finishes.`);
783 >
784 > return parts.join('');
785 > }
787 > function createBashModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { copilotShellTools.ts ×1
788 > return [
789 > 'This tool allows you to execute shell commands in a persistent bash terminal session, preserving environment variables, working directory, and other context across multiple commands.',
790 > createGenericDescription('bash', isSandboxEnabled, networkDomains),
791 > '- Use [[ ]] for conditional tests instead of [ ]',
792 > '- Prefer $() over backticks for command substitution',
793 > '- Use set -e at start of complex commands to exit on errors'
794 > ].join('\n');
795 > }
797 > function createZshModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { copilotShellTools.ts ×1
798 > return [
799 > 'This tool allows you to execute shell commands in a persistent zsh terminal session, preserving environment variables, working directory, and other context across multiple commands.',
800 > createGenericDescription('bash', isSandboxEnabled, networkDomains),
801 > '- Use type to check command type (builtin, function, alias)',
802 > '- Use jobs, fg, bg for job control',
803 > '- Use [[ ]] for conditional tests instead of [ ]',
804 > '- Prefer $() over backticks for command substitution',
805 > '- Take advantage of zsh globbing features (**, extended globs). Note: unmatched globs fail by default (zsh: no matches found) - use a glob qualifier like *(N) or quote the glob if it should be literal',
806 > '',
807 > 'zsh pitfalls - these WILL cause errors or hangs:',
808 > '- NEVER use bare == or === as separators (e.g. echo === triggers zsh equals expansion). Quote them: echo \'===\'',
809 > '- NEVER use status as a variable name (it is read-only in zsh). Use exit_code or ret instead',
810 > ].join('\n');
811 > }