Skip to content

Commit 5281f6f

Browse files
committed
feat(hooks): 新增 Function 和 HTTP Hook 支持
refactor(verify): 重构 AutoVerifyStage 使用 VerifyQueue 优化性能 新增 Function Hook 支持进程内函数注册,适用于 SDK/插件扩展和单元测试场景。添加 HTTP Hook 支持远程 Webhook 调用,包含严格的安全检查机制(SSRF/TLS/白名单)。重构 AutoVerifyStage 使用 VerifyQueue 实现并发合并、短期缓存和增量 tsc 检查,显著提升连续 Edit 操作的性能。 test: 添加 Function 和 HTTP Hook 的单元测试
1 parent 0af1b62 commit 5281f6f

10 files changed

Lines changed: 1684 additions & 118 deletions

File tree

packages/cli/src/hooks/HookExecutor.ts

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,26 @@
66

77
import { OutputParser } from './OutputParser.js';
88
import { SecureProcessExecutor } from './SecureProcessExecutor.js';
9+
import {
10+
substituteEnvVars,
11+
validateHookUrl,
12+
} from './HttpHookSecurity.js';
913
import {
1014
type CommandHook,
1115
type CompactionHookResult,
16+
type FunctionHook,
1217
type Hook,
1318
type HookExecutionContext,
1419
type HookExecutionResult,
1520
type HookInput,
1621
HookType,
22+
type HttpHook,
1723
type NotificationHookResult,
1824
type PermissionRequestHookResult,
1925
type PostToolHookResult,
2026
type PostToolUseFailureHookResult,
2127
type PreToolHookResult,
28+
type ProcessResult,
2229
type PromptHook,
2330
type SessionEndHookResult,
2431
type SessionStartHookResult,
@@ -686,6 +693,14 @@ export class HookExecutor {
686693
return this.executePromptHook(hook, input, context);
687694
}
688695

696+
if (hook.type === HookType.Function) {
697+
return this.executeFunctionHook(hook, input, context);
698+
}
699+
700+
if (hook.type === HookType.Http) {
701+
return this.executeHttpHook(hook, input, context);
702+
}
703+
689704
throw new Error(`Hook type ${(hook as Hook).type} not supported`);
690705
}
691706

@@ -822,6 +837,244 @@ export class HookExecutor {
822837
}
823838
}
824839

840+
/**
841+
* 执行 Function Hook
842+
*
843+
* 直接调用 handler,支持超时/AbortSignal,复用 OutputParser 产出与
844+
* Command/Prompt 一致的 HookExecutionResult。
845+
*/
846+
private async executeFunctionHook(
847+
hook: FunctionHook,
848+
input: HookInput,
849+
context: HookExecutionContext
850+
): Promise<HookExecutionResult> {
851+
const timeoutMs = (hook.timeout ?? 10) * 1000;
852+
853+
try {
854+
const output = await this.runFunctionWithTimeout(
855+
hook.handler,
856+
input,
857+
context,
858+
timeoutMs
859+
);
860+
861+
// undefined / null → 视作 success, 无决策 (pass-through)
862+
if (output === undefined || output === null) {
863+
return { success: true, hook };
864+
}
865+
866+
// 复用 OutputParser: 合成 ProcessResult 走 JSON 验证 + decision 解析
867+
const synthesized: ProcessResult = {
868+
stdout: JSON.stringify(output),
869+
stderr: '',
870+
exitCode: 0,
871+
timedOut: false,
872+
};
873+
return this.outputParser.parse(synthesized, hook, {
874+
timeoutBehavior: context.config.timeoutBehavior,
875+
failureBehavior: context.config.failureBehavior,
876+
});
877+
} catch (err) {
878+
if ((err as { isTimeout?: boolean }).isTimeout) {
879+
const timeoutResult: ProcessResult = {
880+
stdout: '',
881+
stderr: `Function hook timeout after ${timeoutMs}ms`,
882+
exitCode: 124,
883+
timedOut: true,
884+
};
885+
return this.outputParser.parse(timeoutResult, hook, {
886+
timeoutBehavior: context.config.timeoutBehavior,
887+
failureBehavior: context.config.failureBehavior,
888+
});
889+
}
890+
return {
891+
success: false,
892+
blocking: false,
893+
error: err instanceof Error ? err.message : String(err),
894+
hook,
895+
};
896+
}
897+
}
898+
899+
/**
900+
* 带超时地执行 function handler。
901+
* handler 本身不可被强制中止,超时后调用方立即拿到错误,handler 自行清理。
902+
*/
903+
private runFunctionWithTimeout(
904+
handler: FunctionHook['handler'],
905+
input: HookInput,
906+
context: HookExecutionContext,
907+
timeoutMs: number
908+
): Promise<ReturnType<FunctionHook['handler']>> {
909+
return new Promise((resolve, reject) => {
910+
let settled = false;
911+
const timer = setTimeout(() => {
912+
if (settled) return;
913+
settled = true;
914+
const err = Object.assign(
915+
new Error(`Function hook timed out after ${timeoutMs}ms`),
916+
{ isTimeout: true }
917+
);
918+
reject(err);
919+
}, timeoutMs);
920+
921+
const onAbort = () => {
922+
if (settled) return;
923+
settled = true;
924+
clearTimeout(timer);
925+
reject(new Error('Function hook aborted'));
926+
};
927+
context.abortSignal?.addEventListener('abort', onAbort, { once: true });
928+
929+
Promise.resolve()
930+
.then(() => handler(input, context))
931+
.then(
932+
(value) => {
933+
if (settled) return;
934+
settled = true;
935+
clearTimeout(timer);
936+
context.abortSignal?.removeEventListener('abort', onAbort);
937+
resolve(value);
938+
},
939+
(err) => {
940+
if (settled) return;
941+
settled = true;
942+
clearTimeout(timer);
943+
context.abortSignal?.removeEventListener('abort', onAbort);
944+
reject(err);
945+
}
946+
);
947+
});
948+
}
949+
950+
/**
951+
* 执行 HTTP Hook
952+
*
953+
* - 安全检查: validateHookUrl (loopback/私网/TLS/白名单)
954+
* - POST + JSON body (HookInput 原样)
955+
* - 响应 JSON 复用 OutputParser
956+
* - 失败/重试: 指数退避 (2^i * 200ms)
957+
* - 响应大小上限: 默认 256KB
958+
*/
959+
private async executeHttpHook(
960+
hook: HttpHook,
961+
input: HookInput,
962+
context: HookExecutionContext
963+
): Promise<HookExecutionResult> {
964+
const timeoutMs = (hook.timeout ?? 10) * 1000;
965+
const maxBytes = hook.maxResponseBytes ?? 256 * 1024;
966+
const maxAttempts = Math.max(1, (hook.retries ?? 0) + 1);
967+
968+
try {
969+
validateHookUrl(hook.url, context.config.httpPolicy);
970+
} catch (err) {
971+
// 安全检查失败: 视作阻塞错误, 拒绝继续
972+
return {
973+
success: false,
974+
blocking: true,
975+
error: err instanceof Error ? err.message : String(err),
976+
hook,
977+
};
978+
}
979+
980+
const headers = {
981+
'Content-Type': 'application/json',
982+
...substituteEnvVars(hook.headers),
983+
};
984+
const body = JSON.stringify(input);
985+
986+
let lastError: Error | undefined;
987+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
988+
if (attempt > 0) {
989+
const delayMs = Math.min(2 ** attempt * 200, 5000);
990+
await new Promise((r) => setTimeout(r, delayMs));
991+
}
992+
993+
const controller = new AbortController();
994+
const timer = setTimeout(() => controller.abort(), timeoutMs);
995+
const externalAbort = () => controller.abort();
996+
context.abortSignal?.addEventListener('abort', externalAbort, {
997+
once: true,
998+
});
999+
1000+
try {
1001+
const response = await fetch(hook.url, {
1002+
method: 'POST',
1003+
headers,
1004+
body,
1005+
redirect: 'manual', // 不跟随 redirect
1006+
signal: controller.signal,
1007+
});
1008+
1009+
// redirect 视为配置错误
1010+
if (response.status >= 300 && response.status < 400) {
1011+
lastError = new Error(
1012+
`HTTP hook received redirect (${response.status}); redirects are disabled`
1013+
);
1014+
continue;
1015+
}
1016+
1017+
if (!response.ok) {
1018+
lastError = new Error(
1019+
`HTTP hook returned ${response.status} ${response.statusText}`
1020+
);
1021+
// 4xx 不重试 (客户端错误); 5xx 重试
1022+
if (response.status >= 400 && response.status < 500) break;
1023+
continue;
1024+
}
1025+
1026+
// 读取响应 (限大小)
1027+
const bodyText = await readBodyWithLimit(response, maxBytes);
1028+
const synthesized: ProcessResult = {
1029+
stdout: bodyText,
1030+
stderr: '',
1031+
exitCode: 0,
1032+
timedOut: false,
1033+
};
1034+
return this.outputParser.parse(synthesized, hook, {
1035+
timeoutBehavior: context.config.timeoutBehavior,
1036+
failureBehavior: context.config.failureBehavior,
1037+
});
1038+
} catch (err) {
1039+
const isAbort =
1040+
(err as Error).name === 'AbortError' ||
1041+
(err as { code?: string }).code === 'ABORT_ERR';
1042+
if (isAbort && context.abortSignal?.aborted) {
1043+
lastError = new Error('HTTP hook aborted by caller');
1044+
break;
1045+
}
1046+
if (isAbort) {
1047+
// 超时: 按 timeoutBehavior 走
1048+
const timeoutResult: ProcessResult = {
1049+
stdout: '',
1050+
stderr: `HTTP hook timeout after ${timeoutMs}ms`,
1051+
exitCode: 124,
1052+
timedOut: true,
1053+
};
1054+
if (attempt === maxAttempts - 1) {
1055+
return this.outputParser.parse(timeoutResult, hook, {
1056+
timeoutBehavior: context.config.timeoutBehavior,
1057+
failureBehavior: context.config.failureBehavior,
1058+
});
1059+
}
1060+
lastError = new Error(`HTTP hook timeout attempt ${attempt + 1}`);
1061+
continue;
1062+
}
1063+
lastError = err as Error;
1064+
} finally {
1065+
clearTimeout(timer);
1066+
context.abortSignal?.removeEventListener('abort', externalAbort);
1067+
}
1068+
}
1069+
1070+
return {
1071+
success: false,
1072+
blocking: false,
1073+
error: lastError?.message ?? 'HTTP hook failed',
1074+
hook,
1075+
};
1076+
}
1077+
8251078
/**
8261079
* 获取或创建 ChatService 实例(按 modelId 缓存)
8271080
*/
@@ -954,3 +1207,36 @@ export class HookExecutor {
9541207
return Promise.all(results);
9551208
}
9561209
}
1210+
1211+
1212+
/**
1213+
* 读取 fetch Response body, 超过 maxBytes 时截断并附加警告注释
1214+
*/
1215+
async function readBodyWithLimit(
1216+
response: Response,
1217+
maxBytes: number
1218+
): Promise<string> {
1219+
const reader = response.body?.getReader();
1220+
if (!reader) return '';
1221+
1222+
const decoder = new TextDecoder();
1223+
let received = 0;
1224+
let text = '';
1225+
while (true) {
1226+
const { value, done } = await reader.read();
1227+
if (done) break;
1228+
received += value.byteLength;
1229+
if (received > maxBytes) {
1230+
try {
1231+
reader.cancel();
1232+
} catch {
1233+
// ignore
1234+
}
1235+
text += decoder.decode(value.slice(0, maxBytes - (received - value.byteLength)));
1236+
break;
1237+
}
1238+
text += decoder.decode(value, { stream: true });
1239+
}
1240+
text += decoder.decode();
1241+
return text;
1242+
}

0 commit comments

Comments
 (0)