Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/api/node/extHostHooksNode.ts
5223 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 type * as vscode from 'vscode';
7
import { spawn } from 'child_process';
8
import { homedir } from 'os';
9
import * as nls from '../../../nls.js';
10
import { disposableTimeout } from '../../../base/common/async.js';
11
import { CancellationToken } from '../../../base/common/cancellation.js';
12
import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js';
13
import { OS } from '../../../base/common/platform.js';
14
import { URI, isUriComponents } from '../../../base/common/uri.js';
15
import { ILogService } from '../../../platform/log/common/log.js';
16
import { HookTypeValue, getEffectiveCommandSource, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js';
17
import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js';
18
import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js';
19
import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js';
20
import { IExtHostRpcService } from '../common/extHostRpcService.js';
21
import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js';
22
import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js';
23
import * as typeConverters from '../common/extHostTypeConverters.js';
24
25
const SIGKILL_DELAY_MS = 5000;
26
27
export class NodeExtHostHooks implements IExtHostHooks {
28
29
private readonly _mainThreadProxy: MainThreadHooksShape;
30
31
constructor(
32
@IExtHostRpcService extHostRpc: IExtHostRpcService,
33
@ILogService private readonly _logService: ILogService
34
) {
35
this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks);
36
}
37
38
async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise<vscode.ChatHookResult[]> {
39
if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) {
40
throw new Error('Invalid or missing tool invocation token');
41
}
42
43
const context = options.toolInvocationToken as IToolInvocationContext;
44
45
const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None);
46
return results.map(r => typeConverters.ChatHookResult.to(r as IHookResult));
47
}
48
49
async $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise<IHookCommandResult> {
50
this._logService.debug(`[ExtHostHooks] Running hook command: ${JSON.stringify(hookCommand)}`);
51
52
try {
53
return await this._executeCommand(hookCommand, input, token);
54
} catch (err) {
55
return {
56
kind: HookCommandResultKind.Error,
57
result: err instanceof Error ? err.message : String(err)
58
};
59
}
60
}
61
62
private _executeCommand(hook: IHookCommandDto, input: unknown, token?: CancellationToken): Promise<IHookCommandResult> {
63
const home = homedir();
64
const cwdUri = hook.cwd ? URI.revive(hook.cwd) : undefined;
65
const cwd = cwdUri ? cwdUri.fsPath : home;
66
67
// Resolve the effective command for the current platform
68
// This applies windows/linux/osx overrides and falls back to command
69
const effectiveCommand = resolveEffectiveCommand(hook as Parameters<typeof resolveEffectiveCommand>[0], OS);
70
if (!effectiveCommand) {
71
return Promise.resolve({
72
kind: HookCommandResultKind.NonBlockingError,
73
result: nls.localize('noCommandForPlatform', "No command specified for the current platform")
74
});
75
}
76
77
// Execute the command, preserving legacy behavior for explicit shell types:
78
// - powershell source: run through PowerShell so PowerShell-specific commands work
79
// - bash source: run through bash so bash-specific commands work
80
// - otherwise: use default shell via spawn with shell: true
81
const commandSource = getEffectiveCommandSource(hook as Parameters<typeof getEffectiveCommandSource>[0], OS);
82
let shellExecutable: string | undefined;
83
let shellArgs: string[] | undefined;
84
85
if (commandSource === 'powershell') {
86
shellExecutable = 'powershell.exe';
87
shellArgs = ['-Command', effectiveCommand];
88
} else if (commandSource === 'bash') {
89
shellExecutable = 'bash';
90
shellArgs = ['-c', effectiveCommand];
91
}
92
93
const child = shellExecutable && shellArgs
94
? spawn(shellExecutable, shellArgs, {
95
stdio: 'pipe',
96
cwd,
97
env: { ...process.env, ...hook.env },
98
})
99
: spawn(effectiveCommand, [], {
100
stdio: 'pipe',
101
cwd,
102
env: { ...process.env, ...hook.env },
103
shell: true,
104
});
105
106
return new Promise((resolve, reject) => {
107
const stdout: string[] = [];
108
const stderr: string[] = [];
109
let exitCode: number | null = null;
110
let exited = false;
111
112
const disposables = new DisposableStore();
113
const sigkillTimeout = disposables.add(new MutableDisposable());
114
115
const killWithEscalation = () => {
116
if (exited) {
117
return;
118
}
119
child.kill('SIGTERM');
120
sigkillTimeout.value = disposableTimeout(() => {
121
if (!exited) {
122
child.kill('SIGKILL');
123
}
124
}, SIGKILL_DELAY_MS);
125
};
126
127
const cleanup = () => {
128
exited = true;
129
disposables.dispose();
130
};
131
132
// Collect output
133
child.stdout.on('data', data => stdout.push(data.toString()));
134
child.stderr.on('data', data => stderr.push(data.toString()));
135
136
// Set up timeout (default 30 seconds)
137
disposables.add(disposableTimeout(killWithEscalation, (hook.timeoutSec ?? 30) * 1000));
138
139
// Set up cancellation
140
if (token) {
141
disposables.add(token.onCancellationRequested(killWithEscalation));
142
}
143
144
// Write input to stdin
145
if (input !== undefined && input !== null) {
146
try {
147
// Use a replacer to convert URI values to filesystem paths.
148
// URIs arrive as UriComponents objects via the RPC boundary.
149
child.stdin.write(JSON.stringify(input, (_key, value) => {
150
if (isUriComponents(value)) {
151
return URI.revive(value).fsPath;
152
}
153
return value;
154
}));
155
} catch {
156
// Ignore stdin write errors
157
}
158
}
159
child.stdin.end();
160
161
// Capture exit code
162
child.on('exit', code => { exitCode = code; });
163
164
// Resolve on close (after streams flush)
165
child.on('close', () => {
166
cleanup();
167
const code = exitCode ?? 1;
168
const stdoutStr = stdout.join('');
169
const stderrStr = stderr.join('');
170
171
if (code === 0) {
172
// Success - try to parse stdout as JSON, otherwise return as string
173
let result: string | object = stdoutStr;
174
try {
175
result = JSON.parse(stdoutStr);
176
} catch {
177
// Keep as string if not valid JSON
178
}
179
resolve({ kind: HookCommandResultKind.Success, result });
180
} else if (code === 2) {
181
// Blocking error - show stderr to model and stop processing
182
resolve({ kind: HookCommandResultKind.Error, result: stderrStr });
183
} else {
184
// Non-blocking error - show stderr to user only
185
resolve({ kind: HookCommandResultKind.NonBlockingError, result: stderrStr });
186
}
187
});
188
189
child.on('error', err => {
190
cleanup();
191
reject(err);
192
});
193
});
194
}
195
}
196
197