Path: blob/main/src/vs/workbench/api/node/extHostHooksNode.ts
5223 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 type * as vscode from 'vscode';6import { spawn } from 'child_process';7import { homedir } from 'os';8import * as nls from '../../../nls.js';9import { disposableTimeout } from '../../../base/common/async.js';10import { CancellationToken } from '../../../base/common/cancellation.js';11import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js';12import { OS } from '../../../base/common/platform.js';13import { URI, isUriComponents } from '../../../base/common/uri.js';14import { ILogService } from '../../../platform/log/common/log.js';15import { HookTypeValue, getEffectiveCommandSource, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js';16import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js';17import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js';18import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js';19import { IExtHostRpcService } from '../common/extHostRpcService.js';20import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js';21import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js';22import * as typeConverters from '../common/extHostTypeConverters.js';2324const SIGKILL_DELAY_MS = 5000;2526export class NodeExtHostHooks implements IExtHostHooks {2728private readonly _mainThreadProxy: MainThreadHooksShape;2930constructor(31@IExtHostRpcService extHostRpc: IExtHostRpcService,32@ILogService private readonly _logService: ILogService33) {34this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks);35}3637async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise<vscode.ChatHookResult[]> {38if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) {39throw new Error('Invalid or missing tool invocation token');40}4142const context = options.toolInvocationToken as IToolInvocationContext;4344const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None);45return results.map(r => typeConverters.ChatHookResult.to(r as IHookResult));46}4748async $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise<IHookCommandResult> {49this._logService.debug(`[ExtHostHooks] Running hook command: ${JSON.stringify(hookCommand)}`);5051try {52return await this._executeCommand(hookCommand, input, token);53} catch (err) {54return {55kind: HookCommandResultKind.Error,56result: err instanceof Error ? err.message : String(err)57};58}59}6061private _executeCommand(hook: IHookCommandDto, input: unknown, token?: CancellationToken): Promise<IHookCommandResult> {62const home = homedir();63const cwdUri = hook.cwd ? URI.revive(hook.cwd) : undefined;64const cwd = cwdUri ? cwdUri.fsPath : home;6566// Resolve the effective command for the current platform67// This applies windows/linux/osx overrides and falls back to command68const effectiveCommand = resolveEffectiveCommand(hook as Parameters<typeof resolveEffectiveCommand>[0], OS);69if (!effectiveCommand) {70return Promise.resolve({71kind: HookCommandResultKind.NonBlockingError,72result: nls.localize('noCommandForPlatform', "No command specified for the current platform")73});74}7576// Execute the command, preserving legacy behavior for explicit shell types:77// - powershell source: run through PowerShell so PowerShell-specific commands work78// - bash source: run through bash so bash-specific commands work79// - otherwise: use default shell via spawn with shell: true80const commandSource = getEffectiveCommandSource(hook as Parameters<typeof getEffectiveCommandSource>[0], OS);81let shellExecutable: string | undefined;82let shellArgs: string[] | undefined;8384if (commandSource === 'powershell') {85shellExecutable = 'powershell.exe';86shellArgs = ['-Command', effectiveCommand];87} else if (commandSource === 'bash') {88shellExecutable = 'bash';89shellArgs = ['-c', effectiveCommand];90}9192const child = shellExecutable && shellArgs93? spawn(shellExecutable, shellArgs, {94stdio: 'pipe',95cwd,96env: { ...process.env, ...hook.env },97})98: spawn(effectiveCommand, [], {99stdio: 'pipe',100cwd,101env: { ...process.env, ...hook.env },102shell: true,103});104105return new Promise((resolve, reject) => {106const stdout: string[] = [];107const stderr: string[] = [];108let exitCode: number | null = null;109let exited = false;110111const disposables = new DisposableStore();112const sigkillTimeout = disposables.add(new MutableDisposable());113114const killWithEscalation = () => {115if (exited) {116return;117}118child.kill('SIGTERM');119sigkillTimeout.value = disposableTimeout(() => {120if (!exited) {121child.kill('SIGKILL');122}123}, SIGKILL_DELAY_MS);124};125126const cleanup = () => {127exited = true;128disposables.dispose();129};130131// Collect output132child.stdout.on('data', data => stdout.push(data.toString()));133child.stderr.on('data', data => stderr.push(data.toString()));134135// Set up timeout (default 30 seconds)136disposables.add(disposableTimeout(killWithEscalation, (hook.timeoutSec ?? 30) * 1000));137138// Set up cancellation139if (token) {140disposables.add(token.onCancellationRequested(killWithEscalation));141}142143// Write input to stdin144if (input !== undefined && input !== null) {145try {146// Use a replacer to convert URI values to filesystem paths.147// URIs arrive as UriComponents objects via the RPC boundary.148child.stdin.write(JSON.stringify(input, (_key, value) => {149if (isUriComponents(value)) {150return URI.revive(value).fsPath;151}152return value;153}));154} catch {155// Ignore stdin write errors156}157}158child.stdin.end();159160// Capture exit code161child.on('exit', code => { exitCode = code; });162163// Resolve on close (after streams flush)164child.on('close', () => {165cleanup();166const code = exitCode ?? 1;167const stdoutStr = stdout.join('');168const stderrStr = stderr.join('');169170if (code === 0) {171// Success - try to parse stdout as JSON, otherwise return as string172let result: string | object = stdoutStr;173try {174result = JSON.parse(stdoutStr);175} catch {176// Keep as string if not valid JSON177}178resolve({ kind: HookCommandResultKind.Success, result });179} else if (code === 2) {180// Blocking error - show stderr to model and stop processing181resolve({ kind: HookCommandResultKind.Error, result: stderrStr });182} else {183// Non-blocking error - show stderr to user only184resolve({ kind: HookCommandResultKind.NonBlockingError, result: stderrStr });185}186});187188child.on('error', err => {189cleanup();190reject(err);191});192});193}194}195196197