fix(copilot): make Node hook installation transactional

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 12:05:30 -04:00
1 parent cf77d49a3c
commit 2f988e72ba
14 files changed
+1722 -647

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
import { isObject, type JsonObject } from './strict-json.js';
export type CopilotHooks = Record<string, JsonObject[]>;
const SUPPORTED_EVENTS = new Set([
'agentStop',
'Stop',
'errorOccurred',
'ErrorOccurred',
'notification',
'Notification',
'permissionRequest',
'PermissionRequest',
'postToolUse',
'PostToolUse',
'postToolUseFailure',
'PostToolUseFailure',
'preCompact',
'PreCompact',
'preToolUse',
'PreToolUse',
'sessionEnd',
'SessionEnd',
'sessionStart',
'SessionStart',
'subagentStart',
'SubagentStart',
'subagentStop',
'SubagentStop',
'userPromptSubmitted',
'UserPromptSubmit',
'userPromptTransformed',
]);
function fieldLabel(label: string, field: string): string {
return `${label} field "${field}"`;
}
function requireString(value: unknown, label: string): string {
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`${label} must be a non-empty string`);
}
return value;
}
function validateOptionalString(value: unknown, label: string): void {
if (value !== undefined && typeof value !== 'string') {
throw new Error(`${label} must be a string`);
}
}
function validateTimeout(handler: JsonObject, label: string): void {
for (const field of ['timeout', 'timeoutSec']) {
const value = handler[field];
if (value !== undefined && (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)) {
throw new Error(`${fieldLabel(label, field)} must be a positive number`);
}
}
}
function validateMatcher(handler: JsonObject, event: string, label: string): void {
if (handler.matcher === undefined) return;
const matcher = requireString(handler.matcher, fieldLabel(label, 'matcher'));
if ((event === 'PreToolUse' || event === 'PermissionRequest')
&& (matcher === '*' || matcher === '**')) return;
try {
new RegExp(`^(?:${matcher})$`);
} catch (error) {
throw new Error(`${fieldLabel(label, 'matcher')} is not a valid regular expression`, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
}
function validateStringMap(value: unknown, label: string): void {
if (!isObject(value)) throw new Error(`${label} must be an object`);
for (const [key, item] of Object.entries(value)) {
requireString(item, `${label}.${key}`);
}
}
function validateCommand(handler: JsonObject, event: string, label: string): void {
const commands = ['bash', 'powershell', 'command']
.map((field) => handler[field])
.filter((value) => value !== undefined);
if (commands.length === 0) {
throw new Error(`${label} must define bash, powershell, or command`);
}
for (const field of ['bash', 'powershell', 'command', 'cwd']) {
validateOptionalString(handler[field], fieldLabel(label, field));
}
if (handler.env !== undefined) validateStringMap(handler.env, fieldLabel(label, 'env'));
validateTimeout(handler, label);
validateMatcher(handler, event, label);
}
function isLoopback(hostname: string): boolean {
return hostname === 'localhost'
|| hostname === '::1'
|| hostname.startsWith('127.');
}
function validateHttp(handler: JsonObject, event: string, label: string): void {
const rawUrl = requireString(handler.url, fieldLabel(label, 'url'));
let url: URL;
try {
url = new URL(rawUrl);
} catch (error) {
throw new Error(`${fieldLabel(label, 'url')} is invalid`, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
const localhostAllowed = process.env.COPILOT_HOOK_ALLOW_LOCALHOST === '1'
&& url.protocol === 'http:'
&& isLoopback(url.hostname);
if (url.protocol !== 'https:' && !localhostAllowed) {
throw new Error(`${fieldLabel(label, 'url')} must use HTTPS`);
}
if (handler.headers !== undefined) {
validateStringMap(handler.headers, fieldLabel(label, 'headers'));
}
if (handler.allowedEnvVars !== undefined) {
if (!Array.isArray(handler.allowedEnvVars)
|| handler.allowedEnvVars.some((value) => typeof value !== 'string' || !value)) {
throw new Error(`${fieldLabel(label, 'allowedEnvVars')} must be an array of strings`);
}
}
validateTimeout(handler, label);
validateMatcher(handler, event, label);
}
function validatePrompt(handler: JsonObject, event: string, label: string): void {
if (event !== 'sessionStart' && event !== 'SessionStart') {
throw new Error(`${label} prompt hooks are supported only for sessionStart`);
}
requireString(handler.prompt, fieldLabel(label, 'prompt'));
}
function validateHandler(handler: JsonObject, event: string, label: string): void {
const type = handler.type ?? 'command';
if (type === 'command') validateCommand(handler, event, label);
else if (type === 'http') validateHttp(handler, event, label);
else if (type === 'prompt') validatePrompt(handler, event, label);
else throw new Error(`${fieldLabel(label, 'type')} is unsupported`);
}
export function validateHooks(value: unknown, label: string): CopilotHooks {
if (value === undefined) return {};
if (!isObject(value)) throw new Error(`${label} field "hooks" must be an object`);
const hooks: CopilotHooks = {};
for (const [event, handlers] of Object.entries(value)) {
if (!SUPPORTED_EVENTS.has(event)) throw new Error(`${label} hook event "${event}" is unsupported`);
if (!Array.isArray(handlers)) throw new Error(`${label} field "hooks.${event}" must be an array`);
hooks[event] = handlers.map((handler, index) => {
const itemLabel = `${label} handler hooks.${event}[${index}]`;
if (!isObject(handler)) throw new Error(`${itemLabel} must be an object`);
validateHandler(handler, event, itemLabel);
return handler;
});
}
return hooks;
}