qwen-code/integration-tests/hook-integration/mockHttpServer.ts
DennisYu07 b5115e731e
feat(hooks): Add HTTP Hook, Function Hook and Async Hook support (#2827)
* add http/async/function type

* fix url error

* resolve comment

* align cc non blocking error

* fix hookRunner for async

* fix(hooks): update hook type validation to support http and function types

- Change validated hook types from ['command', 'plugin'] to ['command', 'http', 'function']
- Add validation for HTTP hooks requiring url field
- Add validation for function hooks requiring callback field
- Add comprehensive test coverage for all hook type validations

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(hooks): align SSRF protection with Claude Code behavior

- Allow 127.0.0.0/8 (loopback) for local dev hooks
- Allow localhost hostname for local dev hooks
- Allow ::1 (IPv6 loopback) for local dev hooks
- Add 100.64.0.0/10 (CGNAT) to blocked ranges (RFC 6598)
- Update tests to match Claude Code's ssrfGuard.ts behavior

This fixes HTTP hooks failing to connect to local dev servers.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(hooks): align HTTP hook security with Claude Code behavior

- Add CRLF/NUL sanitization for env var interpolation (header injection)
- Implement combined abort signal (external signal + timeout)
- Upgrade SSRF protection to DNS-level with ssrfGuard
  - Allow loopback (127.0.0.0/8, ::1) for local dev hooks
  - Block CGNAT (100.64.0.0/10) and IPv6 private ranges
- Increase default HTTP hook timeout to 10 minutes
- Fix VS Code hooks schema to support http type
  - Add url, headers, allowedEnvVars, async, once, statusMessage, shell fields
  - Note: "function" type is SDK-only (callback cannot be serialized to JSON)

* feat(hooks): enhance Function Hook with messages, skillRoot, shell, and matcher support

- Add MessagesProvider for automatic conversation history passing to function hooks
- Add FunctionHookContext with messages, toolUseID, and signal
- Add skillRoot support for skill-scoped session hooks
- Add shell parameter support for command hooks (bash/powershell)
- Add regex matcher support for hook pattern matching
- Add statusMessage to CommandHookConfig
- Change default function hook timeout from 60s to 5s
- Add comprehensive unit tests for all new features

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* add session hook for skill

* fix function hook parsing

* refactor ui for http hook/async hook/function hook

* update doc and add integration test

* change telemetryn type and refactor SSRF

* fix project level bug

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-16 10:10:33 +08:00

254 lines
6 KiB
TypeScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
createServer,
type Server,
type IncomingMessage,
type ServerResponse,
} from 'http';
/**
* Hook output type for HTTP hook responses
*/
export interface HookOutput {
continue?: boolean;
stopReason?: string;
suppressOutput?: boolean;
systemMessage?: string;
decision?: 'ask' | 'block' | 'deny' | 'approve' | 'allow';
reason?: string;
hookSpecificOutput?: Record<string, unknown>;
}
/**
* Mock HTTP Server for testing HTTP hooks
* Provides endpoints that simulate various hook response scenarios
*/
export class MockHttpServer {
private server: Server | null = null;
private port: number = 0;
private readonly responses: Map<
string,
HookOutput | ((input: Record<string, unknown>) => HookOutput)
> = new Map();
private readonly requestLogs: Array<{
url: string;
body: Record<string, unknown>;
timestamp: number;
}> = [];
/**
* Start the mock server on a random available port
*/
async start(): Promise<number> {
return new Promise((resolve, reject) => {
this.server = createServer((req, res) => {
this.handleRequest(req, res);
});
this.server.listen(0, () => {
const address = this.server!.address();
if (address && typeof address === 'object') {
this.port = address.port;
resolve(this.port);
} else {
reject(new Error('Failed to get server port'));
}
});
this.server.on('error', reject);
});
}
/**
* Stop the mock server
*/
async stop(): Promise<void> {
return new Promise((resolve) => {
if (this.server) {
this.server.close(() => {
this.server = null;
resolve();
});
} else {
resolve();
}
});
}
/**
* Get the server's base URL
*/
getUrl(): string {
return `http://127.0.0.1:${this.port}`;
}
/**
* Set response for a specific path
*/
setResponse(
path: string,
response: HookOutput | ((input: Record<string, unknown>) => HookOutput),
): void {
this.responses.set(path, response);
}
/**
* Get all received request logs
*/
getRequestLogs(): Array<{
url: string;
body: Record<string, unknown>;
timestamp: number;
}> {
return [...this.requestLogs];
}
/**
* Clear request logs
*/
clearRequestLogs(): void {
this.requestLogs.length = 0;
}
/**
* Handle incoming HTTP request
*/
private handleRequest(req: IncomingMessage, res: ServerResponse): void {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', () => {
const parsedBody = JSON.parse(body || '{}');
// Log the request
this.requestLogs.push({
url: req.url || '/',
body: parsedBody,
timestamp: Date.now(),
});
// Find matching response
const response = this.responses.get(req.url || '/');
if (response) {
const output =
typeof response === 'function' ? response(parsedBody) : response;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(output));
} else {
// Default response: allow with continue
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ continue: true }));
}
});
req.on('error', (err) => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
});
}
}
/**
* Pre-defined response scenarios for HTTP hook testing
*/
export const HttpHookResponses = {
/** Allow execution */
allow: { decision: 'allow', continue: true } as HookOutput,
/** Block execution */
block: {
decision: 'block',
reason: 'Blocked by HTTP hook',
continue: false,
} as HookOutput,
/** Ask for permission */
ask: { decision: 'ask', reason: 'User confirmation required' } as HookOutput,
/** Deny execution */
deny: { decision: 'deny', reason: 'Denied by HTTP hook' } as HookOutput,
/** Return additional context */
withContext: (context: string): HookOutput => ({
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: context,
},
}),
/** Return system message */
withSystemMessage: (message: string): HookOutput => ({
continue: true,
systemMessage: message,
}),
/** PreToolUse allow with permission decision */
preToolUseAllow: {
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow',
permissionDecisionReason: 'Tool execution approved by HTTP hook',
},
} as HookOutput,
/** PreToolUse deny with permission decision */
preToolUseDeny: {
continue: false,
decision: 'deny',
reason: 'Tool execution denied by HTTP hook',
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: 'Security policy violation',
},
} as HookOutput,
/** PreToolUse ask for confirmation */
preToolUseAsk: {
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'ask',
permissionDecisionReason: 'Requires user confirmation',
},
} as HookOutput,
/** UserPromptSubmit with additional context */
userPromptSubmitContext: (context: string): HookOutput => ({
continue: true,
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext: context,
},
}),
/** PostToolUse with additional context */
postToolUseContext: (context: string): HookOutput => ({
continue: true,
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: context,
},
}),
/** Stop hook with stop reason */
stopWithReason: (reason: string): HookOutput => ({
continue: true,
stopReason: reason,
hookSpecificOutput: {
hookEventName: 'Stop',
additionalContext: `Stop reason: ${reason}`,
},
}),
};