Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/platform/chat/node/hookExecutor.ts
13401 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 { spawn } from 'child_process';
7
import { homedir } from 'os';
8
import type { CancellationToken, ChatHookCommand, Uri } from 'vscode';
9
import { basename, join } from '../../../util/vs/base/common/path';
10
import { isWindows } from '../../../util/vs/base/common/platform';
11
import { removeAnsiEscapeCodes } from '../../../util/vs/base/common/strings';
12
import { ILogService } from '../../log/common/logService';
13
import { HookCommandResultKind, IHookCommandResult, IHookExecutor } from '../common/hookExecutor';
14
import { IHooksOutputChannel } from '../common/hooksOutputChannel';
15
16
const SIGKILL_DELAY_MS = 5000;
17
const DEFAULT_TIMEOUT_SEC = 30;
18
19
export class NodeHookExecutor implements IHookExecutor {
20
declare readonly _serviceBrand: undefined;
21
22
constructor(
23
@ILogService private readonly _logService: ILogService,
24
@IHooksOutputChannel private readonly _outputChannel: IHooksOutputChannel,
25
) { }
26
27
async executeCommand(
28
hookCommand: ChatHookCommand,
29
input: unknown,
30
token: CancellationToken
31
): Promise<IHookCommandResult> {
32
this._logService.debug(`[HookExecutor] Running hook command: ${hookCommand.command}`);
33
34
try {
35
return await this._spawn(hookCommand, input, token);
36
} catch (err) {
37
// Spawn failures (e.g. command not found) are non-blocking warnings
38
const errMessage = err instanceof Error ? err.message : String(err);
39
const message = `Hook command failed to start: ${hookCommand.command}: ${errMessage}`;
40
this._logService.warn(`[HookExecutor] ${message}`);
41
this._outputChannel.appendLine(`[HookExecutor] ${message}`);
42
return {
43
kind: HookCommandResultKind.NonBlockingError,
44
result: errMessage
45
};
46
}
47
}
48
49
private _spawn(hook: ChatHookCommand, input: unknown, token: CancellationToken): Promise<IHookCommandResult> {
50
const cwd = hook.cwd ? uriToFsPath(hook.cwd) : homedir();
51
52
const child = spawn(hook.command, [], {
53
stdio: 'pipe',
54
cwd,
55
env: { ...process.env, ...hook.env },
56
shell: getShell(),
57
});
58
59
return new Promise((resolve, reject) => {
60
const stdout: string[] = [];
61
const stderr: string[] = [];
62
let exitCode: number | null = null;
63
let exited = false;
64
65
let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
66
let tokenListener: { dispose(): void } | undefined;
67
let killReason: 'timeout' | 'cancelled' | undefined;
68
69
const killWithEscalation = (reason: 'timeout' | 'cancelled') => {
70
if (exited) {
71
return;
72
}
73
killReason = reason;
74
child.kill('SIGTERM');
75
sigkillTimer = setTimeout(() => {
76
if (!exited) {
77
child.kill('SIGKILL');
78
}
79
}, SIGKILL_DELAY_MS);
80
};
81
82
const cleanup = () => {
83
exited = true;
84
if (sigkillTimer) {
85
clearTimeout(sigkillTimer);
86
}
87
clearTimeout(timeoutTimer);
88
tokenListener?.dispose();
89
};
90
91
// Collect output
92
child.stdout.on('data', data => stdout.push(data.toString()));
93
child.stderr.on('data', data => stderr.push(data.toString()));
94
95
// Set up timeout
96
const timeoutTimer = setTimeout(() => killWithEscalation('timeout'), (hook.timeout ?? DEFAULT_TIMEOUT_SEC) * 1000);
97
98
// Set up cancellation
99
if (token) {
100
tokenListener = token.onCancellationRequested(() => killWithEscalation('cancelled'));
101
}
102
103
// Write input to stdin
104
if (input !== undefined && input !== null) {
105
try {
106
child.stdin.write(JSON.stringify(input, (_key, value) => {
107
// Convert URI-like objects to filesystem paths
108
if (isUriLike(value)) {
109
return uriToFsPath(value);
110
}
111
return value;
112
}));
113
} catch {
114
// Ignore stdin write errors
115
}
116
}
117
child.stdin.end();
118
119
// Capture exit code
120
child.on('exit', code => { exitCode = code; });
121
122
// Resolve on close (after streams flush)
123
child.on('close', () => {
124
cleanup();
125
126
if (killReason === 'timeout') {
127
const message = `Hook command timed out after ${hook.timeout ?? DEFAULT_TIMEOUT_SEC}s: ${hook.command}`;
128
this._logService.warn(`[HookExecutor] ${message}`);
129
this._outputChannel.appendLine(`[HookExecutor] ${message}`);
130
} else if (killReason === 'cancelled') {
131
this._outputChannel.appendLine(`[HookExecutor] Hook command was cancelled: ${hook.command}`);
132
}
133
134
const code = exitCode ?? 1;
135
const stdoutStr = stdout.join('');
136
const stderrStr = removeAnsiEscapeCodes(stderr.join(''));
137
138
if (code === 0) {
139
let result: string | object = stdoutStr;
140
if (stdoutStr) {
141
try {
142
result = JSON.parse(stdoutStr);
143
} catch {
144
const message = `Hook command returned non-JSON output: ${hook.command}`;
145
this._logService.warn(`[HookExecutor] ${message}`);
146
this._outputChannel.appendLine(`[HookExecutor] ${message}`);
147
}
148
}
149
resolve({ kind: HookCommandResultKind.Success, result, exitCode: code });
150
} else if (code === 2) {
151
// Exit code 2: blocking error shown to model
152
resolve({ kind: HookCommandResultKind.Error, result: stderrStr, exitCode: code });
153
} else {
154
// Other non-zero: non-blocking warning shown to user only
155
resolve({ kind: HookCommandResultKind.NonBlockingError, result: stderrStr, exitCode: code });
156
}
157
});
158
159
child.on('error', err => {
160
cleanup();
161
reject(err);
162
});
163
});
164
}
165
}
166
167
function isUriLike(value: unknown): value is Uri {
168
return typeof value === 'object' && value !== null && 'scheme' in value && 'path' in value;
169
}
170
171
function uriToFsPath(uri: Uri): string {
172
// vscode.Uri has an fsPath getter
173
if ('fsPath' in uri && typeof uri.fsPath === 'string') {
174
return uri.fsPath;
175
}
176
// Fallback for URI-like objects
177
return (uri as { path: string }).path;
178
}
179
180
181
function getShell(): string | true {
182
if (!isWindows) {
183
return true;
184
}
185
186
const comSpec = process.env.ComSpec;
187
if (!comSpec || basename(comSpec).toLowerCase() !== 'cmd.exe') {
188
return true;
189
}
190
191
const systemRoot = process.env.SystemRoot || process.env.WINDIR;
192
if (!systemRoot) {
193
return true;
194
}
195
196
return join(
197
systemRoot,
198
'System32',
199
'WindowsPowerShell',
200
'v1.0',
201
'powershell.exe'
202
);
203
}
204