fix(copilot): make Node hook installation transactional
This commit is contained in:
1 parent
cf77d49a3c
commit
2f988e72ba
14 files changed
+1722
-647
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user