Path: blob/main/extensions/copilot/src/platform/chat/node/hookExecutor.ts
13401 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 { spawn } from 'child_process';6import { homedir } from 'os';7import type { CancellationToken, ChatHookCommand, Uri } from 'vscode';8import { basename, join } from '../../../util/vs/base/common/path';9import { isWindows } from '../../../util/vs/base/common/platform';10import { removeAnsiEscapeCodes } from '../../../util/vs/base/common/strings';11import { ILogService } from '../../log/common/logService';12import { HookCommandResultKind, IHookCommandResult, IHookExecutor } from '../common/hookExecutor';13import { IHooksOutputChannel } from '../common/hooksOutputChannel';1415const SIGKILL_DELAY_MS = 5000;16const DEFAULT_TIMEOUT_SEC = 30;1718export class NodeHookExecutor implements IHookExecutor {19declare readonly _serviceBrand: undefined;2021constructor(22@ILogService private readonly _logService: ILogService,23@IHooksOutputChannel private readonly _outputChannel: IHooksOutputChannel,24) { }2526async executeCommand(27hookCommand: ChatHookCommand,28input: unknown,29token: CancellationToken30): Promise<IHookCommandResult> {31this._logService.debug(`[HookExecutor] Running hook command: ${hookCommand.command}`);3233try {34return await this._spawn(hookCommand, input, token);35} catch (err) {36// Spawn failures (e.g. command not found) are non-blocking warnings37const errMessage = err instanceof Error ? err.message : String(err);38const message = `Hook command failed to start: ${hookCommand.command}: ${errMessage}`;39this._logService.warn(`[HookExecutor] ${message}`);40this._outputChannel.appendLine(`[HookExecutor] ${message}`);41return {42kind: HookCommandResultKind.NonBlockingError,43result: errMessage44};45}46}4748private _spawn(hook: ChatHookCommand, input: unknown, token: CancellationToken): Promise<IHookCommandResult> {49const cwd = hook.cwd ? uriToFsPath(hook.cwd) : homedir();5051const child = spawn(hook.command, [], {52stdio: 'pipe',53cwd,54env: { ...process.env, ...hook.env },55shell: getShell(),56});5758return new Promise((resolve, reject) => {59const stdout: string[] = [];60const stderr: string[] = [];61let exitCode: number | null = null;62let exited = false;6364let sigkillTimer: ReturnType<typeof setTimeout> | undefined;65let tokenListener: { dispose(): void } | undefined;66let killReason: 'timeout' | 'cancelled' | undefined;6768const killWithEscalation = (reason: 'timeout' | 'cancelled') => {69if (exited) {70return;71}72killReason = reason;73child.kill('SIGTERM');74sigkillTimer = setTimeout(() => {75if (!exited) {76child.kill('SIGKILL');77}78}, SIGKILL_DELAY_MS);79};8081const cleanup = () => {82exited = true;83if (sigkillTimer) {84clearTimeout(sigkillTimer);85}86clearTimeout(timeoutTimer);87tokenListener?.dispose();88};8990// Collect output91child.stdout.on('data', data => stdout.push(data.toString()));92child.stderr.on('data', data => stderr.push(data.toString()));9394// Set up timeout95const timeoutTimer = setTimeout(() => killWithEscalation('timeout'), (hook.timeout ?? DEFAULT_TIMEOUT_SEC) * 1000);9697// Set up cancellation98if (token) {99tokenListener = token.onCancellationRequested(() => killWithEscalation('cancelled'));100}101102// Write input to stdin103if (input !== undefined && input !== null) {104try {105child.stdin.write(JSON.stringify(input, (_key, value) => {106// Convert URI-like objects to filesystem paths107if (isUriLike(value)) {108return uriToFsPath(value);109}110return value;111}));112} catch {113// Ignore stdin write errors114}115}116child.stdin.end();117118// Capture exit code119child.on('exit', code => { exitCode = code; });120121// Resolve on close (after streams flush)122child.on('close', () => {123cleanup();124125if (killReason === 'timeout') {126const message = `Hook command timed out after ${hook.timeout ?? DEFAULT_TIMEOUT_SEC}s: ${hook.command}`;127this._logService.warn(`[HookExecutor] ${message}`);128this._outputChannel.appendLine(`[HookExecutor] ${message}`);129} else if (killReason === 'cancelled') {130this._outputChannel.appendLine(`[HookExecutor] Hook command was cancelled: ${hook.command}`);131}132133const code = exitCode ?? 1;134const stdoutStr = stdout.join('');135const stderrStr = removeAnsiEscapeCodes(stderr.join(''));136137if (code === 0) {138let result: string | object = stdoutStr;139if (stdoutStr) {140try {141result = JSON.parse(stdoutStr);142} catch {143const message = `Hook command returned non-JSON output: ${hook.command}`;144this._logService.warn(`[HookExecutor] ${message}`);145this._outputChannel.appendLine(`[HookExecutor] ${message}`);146}147}148resolve({ kind: HookCommandResultKind.Success, result, exitCode: code });149} else if (code === 2) {150// Exit code 2: blocking error shown to model151resolve({ kind: HookCommandResultKind.Error, result: stderrStr, exitCode: code });152} else {153// Other non-zero: non-blocking warning shown to user only154resolve({ kind: HookCommandResultKind.NonBlockingError, result: stderrStr, exitCode: code });155}156});157158child.on('error', err => {159cleanup();160reject(err);161});162});163}164}165166function isUriLike(value: unknown): value is Uri {167return typeof value === 'object' && value !== null && 'scheme' in value && 'path' in value;168}169170function uriToFsPath(uri: Uri): string {171// vscode.Uri has an fsPath getter172if ('fsPath' in uri && typeof uri.fsPath === 'string') {173return uri.fsPath;174}175// Fallback for URI-like objects176return (uri as { path: string }).path;177}178179180function getShell(): string | true {181if (!isWindows) {182return true;183}184185const comSpec = process.env.ComSpec;186if (!comSpec || basename(comSpec).toLowerCase() !== 'cmd.exe') {187return true;188}189190const systemRoot = process.env.SystemRoot || process.env.WINDIR;191if (!systemRoot) {192return true;193}194195return join(196systemRoot,197'System32',198'WindowsPowerShell',199'v1.0',200'powershell.exe'201);202}203204