mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
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>
This commit is contained in:
parent
70396d1276
commit
b5115e731e
63 changed files with 9301 additions and 469 deletions
|
|
@ -452,7 +452,17 @@ class QwenAgent implements Agent {
|
|||
continue: false,
|
||||
};
|
||||
|
||||
const config = await loadCliConfig(settings, argvForSession, cwd, []);
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
argvForSession,
|
||||
cwd,
|
||||
[],
|
||||
// Pass separated hooks for proper source attribution
|
||||
{
|
||||
userHooks: this.settings.getUserHooks(),
|
||||
projectHooks: this.settings.getProjectHooks(),
|
||||
},
|
||||
);
|
||||
await config.initialize();
|
||||
return config;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,11 @@ export async function handleQwenAuth(
|
|||
minimalArgv,
|
||||
process.cwd(),
|
||||
[], // No extensions for auth command
|
||||
// Pass separated hooks for proper source attribution
|
||||
{
|
||||
userHooks: settings.getUserHooks(),
|
||||
projectHooks: settings.getProjectHooks(),
|
||||
},
|
||||
);
|
||||
|
||||
if (command === 'qwen-oauth') {
|
||||
|
|
|
|||
|
|
@ -701,6 +701,14 @@ export async function loadCliConfig(
|
|||
argv: CliArgs,
|
||||
cwd: string = process.cwd(),
|
||||
overrideExtensions?: string[],
|
||||
/**
|
||||
* Optional separated hooks for proper source attribution.
|
||||
* If provided, these override settings.hooks for hook loading.
|
||||
*/
|
||||
hooksConfig?: {
|
||||
userHooks?: Record<string, unknown>;
|
||||
projectHooks?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<Config> {
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
|
|
@ -1099,6 +1107,7 @@ export async function loadCliConfig(
|
|||
generationConfigSources: resolvedCliConfig.sources,
|
||||
generationConfig: resolvedCliConfig.generationConfig,
|
||||
warnings: resolvedCliConfig.warnings,
|
||||
allowedHttpHookUrls: settings.security?.allowedHttpHookUrls ?? [],
|
||||
cliVersion: await getCliVersion(),
|
||||
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
|
||||
ideMode,
|
||||
|
|
@ -1119,7 +1128,10 @@ export async function loadCliConfig(
|
|||
output: {
|
||||
format: outputSettingsFormat,
|
||||
},
|
||||
hooks: settings.hooks,
|
||||
// Use separated hooks if provided, otherwise fall back to merged hooks
|
||||
userHooks: hooksConfig?.userHooks ?? settings.hooks,
|
||||
projectHooks: hooksConfig?.projectHooks,
|
||||
hooks: settings.hooks, // Keep for backward compatibility
|
||||
disableAllHooks: settings.disableAllHooks ?? false,
|
||||
channel: argv.channel,
|
||||
// Precedence: explicit CLI flag > settings file > default(true).
|
||||
|
|
|
|||
|
|
@ -437,6 +437,26 @@ export class LoadedSettings {
|
|||
this._merged = this.computeMergedSettings();
|
||||
saveSettings(settingsFile, createSettingsUpdate(key, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-level hooks from user settings (not merged with workspace).
|
||||
* These hooks should always be loaded regardless of folder trust.
|
||||
*/
|
||||
getUserHooks(): Record<string, unknown> | undefined {
|
||||
return this.user.settings.hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project-level hooks from workspace settings (not merged).
|
||||
* Returns undefined if workspace is not trusted (hooks filtered out).
|
||||
*/
|
||||
getProjectHooks(): Record<string, unknown> | undefined {
|
||||
// Only return project hooks if workspace is trusted
|
||||
if (!this.isTrusted) {
|
||||
return undefined;
|
||||
}
|
||||
return this.workspace.settings.hooks;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -131,18 +131,36 @@ const HOOK_DEFINITION_ITEMS: SettingItemDefinition = {
|
|||
items: {
|
||||
type: 'object',
|
||||
description:
|
||||
'A hook configuration entry that defines a command to execute.',
|
||||
'A hook configuration entry that defines a hook to execute.',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'The type of hook.',
|
||||
enum: ['command'],
|
||||
description:
|
||||
'The type of hook. Note: "function" type is only available via SDK registration, not settings.json.',
|
||||
enum: ['command', 'http'],
|
||||
required: true,
|
||||
},
|
||||
command: {
|
||||
type: 'string',
|
||||
description: 'The command to execute when the hook is triggered.',
|
||||
required: true,
|
||||
description:
|
||||
'The command to execute when the hook is triggered. Required for "command" type.',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The URL to send the POST request to. Required for "http" type.',
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description:
|
||||
'HTTP headers to include in the request. Supports env var interpolation ($VAR, ${VAR}).',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
allowedEnvVars: {
|
||||
type: 'array',
|
||||
description:
|
||||
'List of environment variables allowed for interpolation in headers and URL.',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
|
|
@ -154,7 +172,7 @@ const HOOK_DEFINITION_ITEMS: SettingItemDefinition = {
|
|||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in milliseconds for the hook execution.',
|
||||
description: 'Timeout in seconds for the hook execution.',
|
||||
},
|
||||
env: {
|
||||
type: 'object',
|
||||
|
|
@ -162,6 +180,25 @@ const HOOK_DEFINITION_ITEMS: SettingItemDefinition = {
|
|||
'Environment variables to set when executing the hook command.',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
async: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether to execute the hook asynchronously (non-blocking, for "command" type only).',
|
||||
},
|
||||
once: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether to execute the hook only once per session (for "http" type).',
|
||||
},
|
||||
statusMessage: {
|
||||
type: 'string',
|
||||
description: 'A message to display while the hook is executing.',
|
||||
},
|
||||
shell: {
|
||||
type: 'string',
|
||||
description: 'The shell to use for command execution.',
|
||||
enum: ['bash', 'powershell'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -1338,6 +1375,20 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
},
|
||||
allowedHttpHookUrls: {
|
||||
type: 'array',
|
||||
label: 'Allowed HTTP Hook URLs',
|
||||
category: 'Security',
|
||||
requiresRestart: false,
|
||||
default: [] as string[],
|
||||
description:
|
||||
'Whitelist of URL patterns for HTTP hooks. Supports * wildcard. If empty, all URLs are allowed (subject to SSRF protection).',
|
||||
showInDialog: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
description: 'URL pattern (supports * wildcard)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -194,6 +194,8 @@ describe('gemini.tsx main function', () => {
|
|||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
migrationWarnings: [],
|
||||
getUserHooks: () => undefined,
|
||||
getProjectHooks: () => undefined,
|
||||
} as never);
|
||||
try {
|
||||
await main();
|
||||
|
|
@ -327,6 +329,8 @@ describe('gemini.tsx main function', () => {
|
|||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
migrationWarnings: [],
|
||||
getUserHooks: () => undefined,
|
||||
getProjectHooks: () => undefined,
|
||||
} as never);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
|
|
@ -465,6 +469,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
migrationWarnings: [],
|
||||
getUserHooks: () => undefined,
|
||||
getProjectHooks: () => undefined,
|
||||
} as never);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
model: undefined,
|
||||
|
|
@ -564,6 +570,8 @@ describe('startInteractiveUI', () => {
|
|||
hideWindowTitle: false,
|
||||
},
|
||||
},
|
||||
getUserHooks: () => undefined,
|
||||
getProjectHooks: () => undefined,
|
||||
} as LoadedSettings;
|
||||
const mockStartupWarnings = ['warning1'];
|
||||
const mockWorkspaceRoot = '/root';
|
||||
|
|
|
|||
|
|
@ -269,6 +269,11 @@ export async function main() {
|
|||
argv,
|
||||
undefined,
|
||||
[],
|
||||
// Pass separated hooks for proper source attribution
|
||||
{
|
||||
userHooks: settings.getUserHooks(),
|
||||
projectHooks: settings.getProjectHooks(),
|
||||
},
|
||||
);
|
||||
|
||||
if (!settings.merged.security?.auth?.useExternal) {
|
||||
|
|
@ -369,6 +374,11 @@ export async function main() {
|
|||
argv,
|
||||
process.cwd(),
|
||||
argv.extensions,
|
||||
// Pass separated hooks for proper source attribution
|
||||
{
|
||||
userHooks: settings.getUserHooks(),
|
||||
projectHooks: settings.getProjectHooks(),
|
||||
},
|
||||
);
|
||||
profileCheckpoint('after_load_cli_config');
|
||||
|
||||
|
|
|
|||
|
|
@ -650,6 +650,7 @@ export default {
|
|||
'User Settings': 'Benutzereinstellungen',
|
||||
'System Settings': 'Systemeinstellungen',
|
||||
Extensions: 'Erweiterungen',
|
||||
'Session (temporary)': 'Sitzung (temporär)',
|
||||
// Hooks - Status
|
||||
'✓ Enabled': '✓ Aktiviert',
|
||||
'✗ Disabled': '✗ Deaktiviert',
|
||||
|
|
|
|||
|
|
@ -435,6 +435,7 @@ export default {
|
|||
'User Settings': 'ユーザー設定',
|
||||
'System Settings': 'システム設定',
|
||||
Extensions: '拡張機能',
|
||||
'Session (temporary)': 'セッション(一時)',
|
||||
// Hooks - Status
|
||||
'✓ Enabled': '✓ 有効',
|
||||
'✗ Disabled': '✗ 無効',
|
||||
|
|
|
|||
|
|
@ -657,6 +657,7 @@ export default {
|
|||
'User Settings': 'Configurações do Usuário',
|
||||
'System Settings': 'Configurações do Sistema',
|
||||
Extensions: 'Extensões',
|
||||
'Session (temporary)': 'Sessão (temporário)',
|
||||
// Hooks - Status
|
||||
'✓ Enabled': '✓ Ativado',
|
||||
'✗ Disabled': '✗ Desativado',
|
||||
|
|
|
|||
|
|
@ -662,6 +662,7 @@ export default {
|
|||
'User Settings': 'Пользовательские настройки',
|
||||
'System Settings': 'Системные настройки',
|
||||
Extensions: 'Расширения',
|
||||
'Session (temporary)': 'Сессия (временно)',
|
||||
// Hooks - Status
|
||||
'✓ Enabled': '✓ Включен',
|
||||
'✗ Disabled': '✗ Отключен',
|
||||
|
|
|
|||
|
|
@ -687,6 +687,7 @@ export default {
|
|||
'User Settings': '用户设置',
|
||||
'System Settings': '系统设置',
|
||||
Extensions: '扩展',
|
||||
'Session (temporary)': '会话(临时)',
|
||||
// Hooks - Status
|
||||
'✓ Enabled': '✓ 已启用',
|
||||
'✗ Disabled': '✗ 已禁用',
|
||||
|
|
|
|||
|
|
@ -69,20 +69,4 @@ describe('hooksCommand', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
it('should list hooks in non-interactive mode', async () => {
|
||||
const nonInteractiveContext = createMockCommandContext({
|
||||
services: {
|
||||
config: mockConfig,
|
||||
},
|
||||
executionMode: 'non_interactive',
|
||||
});
|
||||
|
||||
const result = await hooksCommand.action!(nonInteractiveContext, '');
|
||||
|
||||
// In non-interactive mode, it should return a message
|
||||
expect(result).toHaveProperty('type', 'message');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import type {
|
|||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import type { HookRegistryEntry } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
HookRegistryEntry,
|
||||
SessionHookEntry,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Format hook source for display
|
||||
|
|
@ -27,18 +30,13 @@ function formatHookSource(source: string): string {
|
|||
return t('System');
|
||||
case 'extensions':
|
||||
return t('Extension');
|
||||
case 'session':
|
||||
return t('Session (temporary)');
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format hook status for display
|
||||
*/
|
||||
function formatHookStatus(enabled: boolean): string {
|
||||
return enabled ? t('✓ Enabled') : t('✗ Disabled');
|
||||
}
|
||||
|
||||
const listCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
get description() {
|
||||
|
|
@ -70,38 +68,105 @@ const listCommand: SlashCommand = {
|
|||
}
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
const allHooks = registry.getAllHooks();
|
||||
const configHooks = registry.getAllHooks();
|
||||
|
||||
if (allHooks.length === 0) {
|
||||
// Get session hooks
|
||||
const sessionId = config.getSessionId();
|
||||
const sessionHooksManager = hookSystem.getSessionHooksManager();
|
||||
const sessionHooks = sessionId
|
||||
? sessionHooksManager.getAllSessionHooks(sessionId)
|
||||
: [];
|
||||
|
||||
const totalHooks = configHooks.length + sessionHooks.length;
|
||||
|
||||
if (totalHooks === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t(
|
||||
'No hooks configured. Add hooks in your settings.json file.',
|
||||
'No hooks configured. Add hooks in your settings.json file or invoke a skill with hooks.',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Group hooks by event
|
||||
const hooksByEvent = new Map<string, HookRegistryEntry[]>();
|
||||
for (const hook of allHooks) {
|
||||
const hooksByEvent = new Map<
|
||||
string,
|
||||
Array<{ hook: HookRegistryEntry | SessionHookEntry; isSession: boolean }>
|
||||
>();
|
||||
|
||||
// Add config hooks
|
||||
for (const hook of configHooks) {
|
||||
const eventName = hook.eventName;
|
||||
if (!hooksByEvent.has(eventName)) {
|
||||
hooksByEvent.set(eventName, []);
|
||||
}
|
||||
hooksByEvent.get(eventName)!.push(hook);
|
||||
hooksByEvent.get(eventName)!.push({ hook, isSession: false });
|
||||
}
|
||||
|
||||
let output = `**Configured Hooks (${allHooks.length} total)**\n\n`;
|
||||
// Add session hooks
|
||||
for (const hook of sessionHooks) {
|
||||
const eventName = hook.eventName;
|
||||
if (!hooksByEvent.has(eventName)) {
|
||||
hooksByEvent.set(eventName, []);
|
||||
}
|
||||
hooksByEvent.get(eventName)!.push({ hook, isSession: true });
|
||||
}
|
||||
|
||||
let output = `**Configured Hooks (${totalHooks} total)**\n\n`;
|
||||
|
||||
for (const [eventName, hooks] of hooksByEvent) {
|
||||
output += `### ${eventName}\n`;
|
||||
for (const hook of hooks) {
|
||||
const name = hook.config.name || hook.config.command || 'unnamed';
|
||||
const source = formatHookSource(hook.source);
|
||||
const status = formatHookStatus(hook.enabled);
|
||||
const matcher = hook.matcher ? ` (matcher: ${hook.matcher})` : '';
|
||||
output += `- **${name}** [${source}] ${status}${matcher}\n`;
|
||||
for (const { hook, isSession } of hooks) {
|
||||
let name: string;
|
||||
let source: string;
|
||||
let matcher: string;
|
||||
let config: {
|
||||
type: string;
|
||||
command?: string;
|
||||
url?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
if (isSession) {
|
||||
// Session hook
|
||||
const sessionHook = hook as SessionHookEntry;
|
||||
config = sessionHook.config as {
|
||||
type: string;
|
||||
command?: string;
|
||||
url?: string;
|
||||
name?: string;
|
||||
};
|
||||
name =
|
||||
config.name ||
|
||||
(config.type === 'command' ? config.command : undefined) ||
|
||||
(config.type === 'http' ? config.url : undefined) ||
|
||||
'unnamed';
|
||||
source = formatHookSource('session');
|
||||
matcher = sessionHook.matcher
|
||||
? ` (matcher: ${sessionHook.matcher})`
|
||||
: '';
|
||||
} else {
|
||||
// Config hook
|
||||
const configHook = hook as HookRegistryEntry;
|
||||
config = configHook.config as {
|
||||
type: string;
|
||||
command?: string;
|
||||
url?: string;
|
||||
name?: string;
|
||||
};
|
||||
name =
|
||||
config.name ||
|
||||
(config.type === 'command' ? config.command : undefined) ||
|
||||
(config.type === 'http' ? config.url : undefined) ||
|
||||
'unnamed';
|
||||
source = formatHookSource(configHook.source);
|
||||
matcher = configHook.matcher
|
||||
? ` (matcher: ${configHook.matcher})`
|
||||
: '';
|
||||
}
|
||||
|
||||
output += `- **${name}** [${source}]${matcher}\n`;
|
||||
}
|
||||
output += '\n';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Box, Text } from 'ink';
|
|||
import { theme } from '../../semantic-colors.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import type { HookEventDisplayInfo } from './types.js';
|
||||
import { HooksConfigSource } from '@qwen-code/qwen-code-core';
|
||||
import { HooksConfigSource, HookType } from '@qwen-code/qwen-code-core';
|
||||
import { getTranslatedSourceDisplayMap } from './constants.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
|
|
@ -86,13 +86,33 @@ export function HookDetailStep({
|
|||
{hook.configs.map((config, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const sourceDisplay = getConfigSourceDisplay(config);
|
||||
const command =
|
||||
config.config.type === 'command' ? config.config.command : '';
|
||||
|
||||
// Get display text based on hook type
|
||||
let hookDisplay = '';
|
||||
const hookType = config.config.type;
|
||||
|
||||
if (hookType === HookType.Command) {
|
||||
// For command hook, show command (truncate if too long)
|
||||
hookDisplay = config.config.command || '';
|
||||
} else if (hookType === HookType.Http) {
|
||||
// For http hook, show name or url
|
||||
hookDisplay = config.config.name || config.config.url || '';
|
||||
} else if (hookType === HookType.Function) {
|
||||
// For function hook, show name or id
|
||||
hookDisplay =
|
||||
config.config.name || config.config.id || 'function-hook';
|
||||
}
|
||||
|
||||
// Check if this is an async hook (only command hooks support async)
|
||||
const isAsync =
|
||||
hookType === HookType.Command && config.config.async === true;
|
||||
const typeDisplay = isAsync
|
||||
? `${hookType} async`
|
||||
: String(hookType);
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
{/* Left column: selector + command */}
|
||||
{/* Left column: selector + display */}
|
||||
<Box width={commandWidth}>
|
||||
<Box minWidth={2}>
|
||||
<Text
|
||||
|
|
@ -108,7 +128,7 @@ export function HookDetailStep({
|
|||
bold={isSelected}
|
||||
wrap="wrap"
|
||||
>
|
||||
{`${index + 1}. [${hookType}] ${command}`}
|
||||
{`${index + 1}. [${typeDisplay}] ${hookDisplay}`}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* Spacer between columns */}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ vi.mock('../../contexts/ConfigContext.js', async (importOriginal) => {
|
|||
useConfig: vi.fn(() => ({
|
||||
getExtensions: vi.fn(() => []),
|
||||
getDisableAllHooks: vi.fn(() => false),
|
||||
getHookSystem: vi.fn(() => ({
|
||||
getSessionHooksManager: vi.fn(() => ({
|
||||
getAllSessionHooks: vi.fn(() => []),
|
||||
})),
|
||||
})),
|
||||
getSessionId: vi.fn(() => 'test-session-id'),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
|
@ -159,20 +165,6 @@ describe('HooksManagementDialog', () => {
|
|||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle empty hooks list gracefully', async () => {
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<HooksManagementDialog onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const output = lastFrame();
|
||||
// Should show 0 hooks configured when no hooks are configured
|
||||
expect(output).toContain('0 hooks configured');
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard navigation - HOOKS_LIST step', () => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
HooksConfigSource,
|
||||
type HookDefinition,
|
||||
type HookConfig,
|
||||
type SessionHookEntry,
|
||||
createDebugLogger,
|
||||
HOOKS_CONFIG_FIELDS,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -40,13 +41,21 @@ const debugLogger = createDebugLogger('HOOKS_DIALOG');
|
|||
* Type guard to check if a value is a valid HookConfig
|
||||
*/
|
||||
function isValidHookConfig(config: unknown): config is HookConfig {
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
'type' in config &&
|
||||
'command' in config &&
|
||||
typeof (config as HookConfig).command === 'string'
|
||||
);
|
||||
if (typeof config !== 'object' || config === null || !('type' in config)) {
|
||||
return false;
|
||||
}
|
||||
const obj = config as Record<string, unknown>;
|
||||
// Check based on type
|
||||
if (obj['type'] === 'command') {
|
||||
return 'command' in obj && typeof obj['command'] === 'string';
|
||||
}
|
||||
if (obj['type'] === 'http') {
|
||||
return 'url' in obj && typeof obj['url'] === 'string';
|
||||
}
|
||||
if (obj['type'] === 'function') {
|
||||
return 'callback' in obj && typeof obj['callback'] === 'function';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -299,6 +308,33 @@ export function HooksManagementDialog({
|
|||
}
|
||||
}
|
||||
|
||||
// Get session hooks from SessionHooksManager
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (hookSystem) {
|
||||
const sessionId = config.getSessionId();
|
||||
if (sessionId) {
|
||||
const sessionHooksManager = hookSystem.getSessionHooksManager();
|
||||
const allSessionHooks =
|
||||
sessionHooksManager.getAllSessionHooks(sessionId);
|
||||
|
||||
// Filter hooks for this event
|
||||
const eventSessionHooks = allSessionHooks.filter(
|
||||
(hook: SessionHookEntry) => hook.eventName === eventName,
|
||||
);
|
||||
|
||||
for (const sessionHook of eventSessionHooks) {
|
||||
// Session hooks have matcher stored separately from config
|
||||
hookInfo.configs.push({
|
||||
config: sessionHook.config as HookConfig,
|
||||
source: HooksConfigSource.Session,
|
||||
sourceDisplay: t('Session (temporary)'),
|
||||
matcher: sessionHook.matcher,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(hookInfo);
|
||||
}
|
||||
|
||||
|
|
@ -311,7 +347,9 @@ export function HooksManagementDialog({
|
|||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
debugLogger.debug('Fetching hooks data for dialog');
|
||||
const hooksData = fetchHooksData();
|
||||
debugLogger.debug('Hooks data fetched:', hooksData.length, 'events');
|
||||
if (!cancelled) {
|
||||
setHooks(hooksData);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ export function getTranslatedSourceDisplayMap(): Record<
|
|||
[HooksConfigSource.User]: t('User Settings'),
|
||||
[HooksConfigSource.System]: t('System Settings'),
|
||||
[HooksConfigSource.Extensions]: t('Extensions'),
|
||||
[HooksConfigSource.Session]: t('Session (temporary)'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export interface HookConfigDisplayInfo {
|
|||
source: HooksConfigSource;
|
||||
sourceDisplay: string;
|
||||
sourcePath?: string;
|
||||
matcher?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,6 +103,8 @@ import {
|
|||
PermissionMode,
|
||||
NotificationType,
|
||||
type PermissionSuggestion,
|
||||
type HookEventName,
|
||||
type HookDefinition,
|
||||
} from '../hooks/types.js';
|
||||
import { fireNotificationHook } from '../core/toolHookTriggers.js';
|
||||
|
||||
|
|
@ -447,10 +449,23 @@ export interface ConfigParameters {
|
|||
* to use disableAllHooks instead (note: inverted logic - enabled:true → disableAllHooks:false).
|
||||
*/
|
||||
disableAllHooks?: boolean;
|
||||
/** Hooks configuration from settings */
|
||||
/**
|
||||
* User-level hooks configuration (from user settings).
|
||||
* These hooks are always loaded regardless of folder trust status.
|
||||
*/
|
||||
userHooks?: Record<string, unknown>;
|
||||
/**
|
||||
* Project-level hooks configuration (from workspace settings).
|
||||
* These hooks are only loaded in trusted folders.
|
||||
* When undefined or the folder is untrusted, project hooks are skipped.
|
||||
*/
|
||||
projectHooks?: Record<string, unknown>;
|
||||
|
||||
hooks?: Record<string, unknown>;
|
||||
/** Warnings generated during configuration resolution */
|
||||
warnings?: string[];
|
||||
/** Allowed HTTP hook URLs whitelist (from security.allowedHttpHookUrls) */
|
||||
allowedHttpHookUrls?: string[];
|
||||
/**
|
||||
* Callback for persisting a permission rule to settings.
|
||||
* Injected by the CLI layer; core uses this to write allow/ask/deny rules
|
||||
|
|
@ -609,6 +624,7 @@ export class Config {
|
|||
private readonly skipLoopDetection: boolean;
|
||||
private readonly skipStartupContext: boolean;
|
||||
private readonly warnings: string[];
|
||||
private readonly allowedHttpHookUrls: string[];
|
||||
private readonly onPersistPermissionRuleCallback?: (
|
||||
scope: 'project' | 'user',
|
||||
ruleType: 'allow' | 'ask' | 'deny',
|
||||
|
|
@ -623,6 +639,11 @@ export class Config {
|
|||
private readonly channel: string | undefined;
|
||||
private readonly defaultFileEncoding: FileEncodingType | undefined;
|
||||
private readonly disableAllHooks: boolean;
|
||||
/** User-level hooks (always loaded regardless of trust) */
|
||||
private readonly userHooks?: Record<string, unknown>;
|
||||
/** Project-level hooks (only loaded in trusted folders) */
|
||||
private readonly projectHooks?: Record<string, unknown>;
|
||||
/** @deprecated Legacy merged hooks field - use userHooks/projectHooks instead */
|
||||
private readonly hooks?: Record<string, unknown>;
|
||||
private hookSystem?: HookSystem;
|
||||
private messageBus?: MessageBus;
|
||||
|
|
@ -732,6 +753,7 @@ export class Config {
|
|||
this.skipLoopDetection = params.skipLoopDetection ?? false;
|
||||
this.skipStartupContext = params.skipStartupContext ?? false;
|
||||
this.warnings = params.warnings ?? [];
|
||||
this.allowedHttpHookUrls = params.allowedHttpHookUrls ?? [];
|
||||
this.onPersistPermissionRuleCallback = params.onPersistPermissionRule;
|
||||
|
||||
// Web search
|
||||
|
|
@ -798,6 +820,10 @@ export class Config {
|
|||
isWorkspaceTrusted: this.isTrustedFolder(),
|
||||
});
|
||||
this.disableAllHooks = params.disableAllHooks ?? false;
|
||||
// Store user and project hooks separately for proper source attribution
|
||||
this.userHooks = params.userHooks;
|
||||
this.projectHooks = params.projectHooks;
|
||||
// Legacy: fall back to merged hooks if new fields are not provided
|
||||
this.hooks = params.hooks;
|
||||
}
|
||||
|
||||
|
|
@ -1919,20 +1945,28 @@ export class Config {
|
|||
|
||||
/**
|
||||
* Get project-level hooks configuration.
|
||||
* This is used by the HookRegistry to load project-specific hooks.
|
||||
* Returns hooks from workspace settings, only in trusted folders.
|
||||
* Used by HookRegistry to load project-specific hooks with proper source attribution.
|
||||
*/
|
||||
getProjectHooks(): Record<string, unknown> | undefined {
|
||||
// This will be populated from settings by the CLI layer
|
||||
// The core Config doesn't have direct access to settings
|
||||
return undefined;
|
||||
getProjectHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined {
|
||||
// Only return project hooks if workspace is trusted
|
||||
if (!this.isTrustedFolder()) {
|
||||
return undefined;
|
||||
}
|
||||
// Prefer new projectHooks field, fall back to hooks for backward compatibility
|
||||
const hooks = this.projectHooks ?? this.hooks;
|
||||
return hooks as { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hooks configuration (merged from all sources).
|
||||
* This is used by the HookRegistry to load hooks.
|
||||
* Get user-level hooks configuration.
|
||||
* Returns hooks from user settings, always available regardless of folder trust.
|
||||
* Used by HookRegistry to load user-specific hooks with proper source attribution.
|
||||
*/
|
||||
getHooks(): Record<string, unknown> | undefined {
|
||||
return this.hooks;
|
||||
getUserHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined {
|
||||
// Prefer new userHooks field, fall back to hooks for backward compatibility
|
||||
const hooks = this.userHooks ?? this.hooks;
|
||||
return hooks as { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
}
|
||||
|
||||
getExtensions(): Extension[] {
|
||||
|
|
@ -2010,6 +2044,14 @@ export class Config {
|
|||
return this.folderTrust;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the whitelist of allowed HTTP hook URL patterns.
|
||||
* If empty, all URLs are allowed (subject to SSRF protection).
|
||||
*/
|
||||
getAllowedHttpHookUrls(): string[] {
|
||||
return this.allowedHttpHookUrls;
|
||||
}
|
||||
|
||||
isTrustedFolder(): boolean {
|
||||
// isWorkspaceTrusted in cli/src/config/trustedFolder.js returns undefined
|
||||
// when the file based trust value is unavailable, since it is mainly used
|
||||
|
|
|
|||
|
|
@ -504,9 +504,10 @@ describe('convertClaudePluginPackage', () => {
|
|||
expect(result.config.hooks).toBeDefined();
|
||||
expect(result.config.hooks!['PostToolUse']).toHaveLength(1);
|
||||
// Check that the variable was substituted
|
||||
expect(result.config.hooks!['PostToolUse']![0].hooks![0].command).toBe(
|
||||
`${pluginSourceDir}/scripts/post-install.sh`,
|
||||
);
|
||||
expect(
|
||||
(result.config.hooks!['PostToolUse']![0].hooks![0] as { command: string })
|
||||
.command,
|
||||
).toBe(`${pluginSourceDir}/scripts/post-install.sh`);
|
||||
|
||||
// Clean up converted directory
|
||||
fs.rmSync(result.convertedDir, { recursive: true, force: true });
|
||||
|
|
|
|||
|
|
@ -808,9 +808,13 @@ describe('extension tests', () => {
|
|||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].hooks).toBeDefined();
|
||||
expect(extensions[0].hooks!['PreToolUse']).toHaveLength(1);
|
||||
expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe(
|
||||
'echo "hello"',
|
||||
);
|
||||
expect(
|
||||
(
|
||||
extensions[0].hooks!['PreToolUse']![0].hooks![0] as {
|
||||
command: string;
|
||||
}
|
||||
).command,
|
||||
).toBe('echo "hello"');
|
||||
});
|
||||
|
||||
it('should load hooks from hooks/hooks.json when not in main config', async () => {
|
||||
|
|
@ -861,9 +865,13 @@ describe('extension tests', () => {
|
|||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].hooks).toBeDefined();
|
||||
expect(extensions[0].hooks!['PostToolUse']).toHaveLength(1);
|
||||
expect(extensions[0].hooks!['PostToolUse']![0].hooks![0].command).toBe(
|
||||
`echo "installed in ${extensionDir}"`,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
extensions[0].hooks!['PostToolUse']![0].hooks![0] as {
|
||||
command: string;
|
||||
}
|
||||
).command,
|
||||
).toBe(`echo "installed in ${extensionDir}"`);
|
||||
});
|
||||
|
||||
it('should substitute ${CLAUDE_PLUGIN_ROOT} variable in hooks', async () => {
|
||||
|
|
@ -901,9 +909,13 @@ describe('extension tests', () => {
|
|||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].hooks).toBeDefined();
|
||||
expect(extensions[0].hooks!['PreToolUse']).toHaveLength(1);
|
||||
expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe(
|
||||
`${extensionDir}/scripts/setup.sh`,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
extensions[0].hooks!['PreToolUse']![0].hooks![0] as {
|
||||
command: string;
|
||||
}
|
||||
).command,
|
||||
).toBe(`${extensionDir}/scripts/setup.sh`);
|
||||
});
|
||||
|
||||
it('should load hooks from config.hooks string path', async () => {
|
||||
|
|
@ -955,9 +967,13 @@ describe('extension tests', () => {
|
|||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].hooks).toBeDefined();
|
||||
expect(extensions[0].hooks!['PreToolUse']).toHaveLength(1);
|
||||
expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe(
|
||||
'echo "custom hooks path"',
|
||||
);
|
||||
expect(
|
||||
(
|
||||
extensions[0].hooks!['PreToolUse']![0].hooks![0] as {
|
||||
command: string;
|
||||
}
|
||||
).command,
|
||||
).toBe('echo "custom hooks path"');
|
||||
});
|
||||
|
||||
it('should prefer config.hooks string path over hooks/hooks.json', async () => {
|
||||
|
|
@ -1013,9 +1029,13 @@ describe('extension tests', () => {
|
|||
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].hooks).toBeDefined();
|
||||
expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe(
|
||||
'echo "config path"',
|
||||
);
|
||||
expect(
|
||||
(
|
||||
extensions[0].hooks!['PreToolUse']![0].hooks![0] as {
|
||||
command: string;
|
||||
}
|
||||
).command,
|
||||
).toBe('echo "config path"');
|
||||
});
|
||||
|
||||
it('should substitute ${CLAUDE_PLUGIN_ROOT} in hooks file from config.hooks string path', async () => {
|
||||
|
|
@ -1065,9 +1085,13 @@ describe('extension tests', () => {
|
|||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].hooks).toBeDefined();
|
||||
expect(extensions[0].hooks!['PreToolUse']).toHaveLength(1);
|
||||
expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe(
|
||||
`${extensionDir}/scripts/setup.sh`,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
extensions[0].hooks!['PreToolUse']![0].hooks![0] as {
|
||||
command: string;
|
||||
}
|
||||
).command,
|
||||
).toBe(`${extensionDir}/scripts/setup.sh`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ describe('substituteHookVariables', () => {
|
|||
description: 'Setup before start',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
type: HookType.Command as const,
|
||||
command: '${CLAUDE_PLUGIN_ROOT}/scripts/setup.sh',
|
||||
},
|
||||
],
|
||||
|
|
@ -47,9 +47,9 @@ describe('substituteHookVariables', () => {
|
|||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!['PreToolUse']).toHaveLength(1);
|
||||
expect(result!['PreToolUse']![0].hooks![0].command).toBe(
|
||||
'/path/to/plugin/scripts/setup.sh',
|
||||
);
|
||||
expect(
|
||||
(result!['PreToolUse']![0].hooks![0] as { command: string }).command,
|
||||
).toBe('/path/to/plugin/scripts/setup.sh');
|
||||
});
|
||||
|
||||
it('should handle multiple hooks with variables', () => {
|
||||
|
|
@ -61,7 +61,7 @@ describe('substituteHookVariables', () => {
|
|||
description: 'Post install hook 1',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
type: HookType.Command as const,
|
||||
command: '${CLAUDE_PLUGIN_ROOT}/bin/init.sh',
|
||||
},
|
||||
],
|
||||
|
|
@ -70,7 +70,7 @@ describe('substituteHookVariables', () => {
|
|||
description: 'Post install hook 2',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
type: HookType.Command as const,
|
||||
command: 'chmod +x ${CLAUDE_PLUGIN_ROOT}/bin/executable.sh',
|
||||
},
|
||||
],
|
||||
|
|
@ -82,12 +82,12 @@ describe('substituteHookVariables', () => {
|
|||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!['PostToolUse']).toHaveLength(2);
|
||||
expect(result!['PostToolUse']![0].hooks![0].command).toBe(
|
||||
'/project/plugins/my-plugin/bin/init.sh',
|
||||
);
|
||||
expect(result!['PostToolUse']![1].hooks![0].command).toBe(
|
||||
'chmod +x /project/plugins/my-plugin/bin/executable.sh',
|
||||
);
|
||||
expect(
|
||||
(result!['PostToolUse']![0].hooks![0] as { command: string }).command,
|
||||
).toBe('/project/plugins/my-plugin/bin/init.sh');
|
||||
expect(
|
||||
(result!['PostToolUse']![1].hooks![0] as { command: string }).command,
|
||||
).toBe('chmod +x /project/plugins/my-plugin/bin/executable.sh');
|
||||
});
|
||||
|
||||
it('should handle multiple event types with hooks', () => {
|
||||
|
|
@ -101,7 +101,7 @@ describe('substituteHookVariables', () => {
|
|||
hooks: [
|
||||
// HookConfig[] array inside HookDefinition
|
||||
{
|
||||
type: HookType.Command, // HookType.Command
|
||||
type: HookType.Command as const, // HookType.Command
|
||||
command: '${CLAUDE_PLUGIN_ROOT}/scripts/pre-start.sh',
|
||||
},
|
||||
],
|
||||
|
|
@ -114,7 +114,7 @@ describe('substituteHookVariables', () => {
|
|||
hooks: [
|
||||
// HookConfig[] array inside HookDefinition
|
||||
{
|
||||
type: HookType.Command, // HookType.Command
|
||||
type: HookType.Command as const, // HookType.Command
|
||||
command: '${CLAUDE_PLUGIN_ROOT}/setup/install.py',
|
||||
},
|
||||
],
|
||||
|
|
@ -126,13 +126,14 @@ describe('substituteHookVariables', () => {
|
|||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!['PreToolUse']).toHaveLength(1);
|
||||
expect(result!['PreToolUse']![0].hooks![0].command).toBe(
|
||||
'/home/user/.qwen/extensions/my-extension/scripts/pre-start.sh',
|
||||
);
|
||||
expect(
|
||||
(result!['PreToolUse']![0].hooks![0] as { command: string }).command,
|
||||
).toBe('/home/user/.qwen/extensions/my-extension/scripts/pre-start.sh');
|
||||
expect(result!['UserPromptSubmit']).toHaveLength(1);
|
||||
expect(result!['UserPromptSubmit']![0].hooks![0].command).toBe(
|
||||
'/home/user/.qwen/extensions/my-extension/setup/install.py',
|
||||
);
|
||||
expect(
|
||||
(result!['UserPromptSubmit']![0].hooks![0] as { command: string })
|
||||
.command,
|
||||
).toBe('/home/user/.qwen/extensions/my-extension/setup/install.py');
|
||||
});
|
||||
|
||||
it('should not modify non-command hooks', () => {
|
||||
|
|
@ -146,7 +147,7 @@ describe('substituteHookVariables', () => {
|
|||
hooks: [
|
||||
// This is the HookConfig[] array inside HookDefinition
|
||||
{
|
||||
type: HookType.Command, // This is part of HookConfig
|
||||
type: HookType.Command as const, // This is part of HookConfig
|
||||
command: '${CLAUDE_PLUGIN_ROOT}/scripts/run.sh', // This is part of HookConfig
|
||||
},
|
||||
{
|
||||
|
|
@ -162,12 +163,12 @@ describe('substituteHookVariables', () => {
|
|||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!['SessionStart']).toHaveLength(1);
|
||||
expect(result!['SessionStart']![0].hooks![0].command).toBe(
|
||||
'/path/to/extension/scripts/run.sh',
|
||||
);
|
||||
expect(result!['SessionStart']![0].hooks![1].command).toBe(
|
||||
'${CLAUDE_PLUGIN_ROOT}/not-affected',
|
||||
); // Non-command type won't be processed
|
||||
expect(
|
||||
(result!['SessionStart']![0].hooks![0] as { command: string }).command,
|
||||
).toBe('/path/to/extension/scripts/run.sh');
|
||||
expect(
|
||||
(result!['SessionStart']![0].hooks![1] as { command: string }).command,
|
||||
).toBe('${CLAUDE_PLUGIN_ROOT}/not-affected'); // Non-command type won't be processed
|
||||
});
|
||||
|
||||
it('should return undefined when hooks is undefined', () => {
|
||||
|
|
@ -186,7 +187,7 @@ describe('substituteHookVariables', () => {
|
|||
hooks: [
|
||||
// This is the HookConfig[] array inside HookDefinition
|
||||
{
|
||||
type: HookType.Command, // This is part of CommandHookConfig
|
||||
type: HookType.Command as const, // This is part of CommandHookConfig
|
||||
command: 'echo "hello world"', // This is part of CommandHookConfig
|
||||
},
|
||||
],
|
||||
|
|
@ -198,7 +199,9 @@ describe('substituteHookVariables', () => {
|
|||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual(hooks); // Should be equal but not the same object (deep clone)
|
||||
expect(result!['Stop']![0].hooks![0].command).toBe('echo "hello world"');
|
||||
expect((result!['Stop']![0].hooks![0] as { command: string }).command).toBe(
|
||||
'echo "hello world"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
517
packages/core/src/hooks/asyncHookRegistry.test.ts
Normal file
517
packages/core/src/hooks/asyncHookRegistry.test.ts
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { AsyncHookRegistry, generateHookId } from './asyncHookRegistry.js';
|
||||
import { HookEventName } from './types.js';
|
||||
|
||||
describe('AsyncHookRegistry', () => {
|
||||
let registry: AsyncHookRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new AsyncHookRegistry();
|
||||
});
|
||||
|
||||
describe('generateHookId', () => {
|
||||
it('should generate unique hook IDs', () => {
|
||||
const id1 = generateHookId();
|
||||
const id2 = generateHookId();
|
||||
expect(id1).not.toBe(id2);
|
||||
expect(id1).toMatch(/^hook_\d+_[a-z0-9]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should register a new async hook', () => {
|
||||
const hookId = registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Test Hook',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
expect(hookId).toBe('test-hook-1');
|
||||
expect(registry.hasRunningHooks()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOutput', () => {
|
||||
it('should update stdout', () => {
|
||||
registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Test Hook',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
registry.updateOutput('test-hook-1', 'stdout data', undefined);
|
||||
|
||||
const pending = registry.getPendingHooks();
|
||||
expect(pending[0].stdout).toBe('stdout data');
|
||||
});
|
||||
|
||||
it('should update stderr', () => {
|
||||
registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Test Hook',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
registry.updateOutput('test-hook-1', undefined, 'stderr data');
|
||||
|
||||
const pending = registry.getPendingHooks();
|
||||
expect(pending[0].stderr).toBe('stderr data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete', () => {
|
||||
it('should mark hook as completed and remove from pending', () => {
|
||||
registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Test Hook',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
registry.complete('test-hook-1', { continue: true });
|
||||
|
||||
expect(registry.hasRunningHooks()).toBe(false);
|
||||
});
|
||||
|
||||
it('should process JSON output for system message', () => {
|
||||
registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Test Hook',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '{"systemMessage": "Build completed"}',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
registry.complete('test-hook-1');
|
||||
|
||||
const output = registry.getPendingOutput();
|
||||
expect(output.messages.length).toBe(1);
|
||||
expect(output.messages[0].message).toBe('Build completed');
|
||||
expect(output.messages[0].type).toBe('system');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail', () => {
|
||||
it('should mark hook as failed and add error message', () => {
|
||||
registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Test Hook',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
registry.fail('test-hook-1', new Error('Hook failed'));
|
||||
|
||||
expect(registry.hasRunningHooks()).toBe(false);
|
||||
const output = registry.getPendingOutput();
|
||||
expect(output.messages.length).toBe(1);
|
||||
expect(output.messages[0].type).toBe('error');
|
||||
expect(output.messages[0].message).toContain('Hook failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeout', () => {
|
||||
it('should mark hook as timed out', () => {
|
||||
registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Test Hook',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 1000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
registry.timeout('test-hook-1');
|
||||
|
||||
expect(registry.hasRunningHooks()).toBe(false);
|
||||
const output = registry.getPendingOutput();
|
||||
expect(output.messages.length).toBe(1);
|
||||
expect(output.messages[0].type).toBe('warning');
|
||||
expect(output.messages[0].message).toContain('timed out');
|
||||
});
|
||||
|
||||
it('should terminate process on timeout', () => {
|
||||
const mockProcess = {
|
||||
killed: false,
|
||||
kill: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
|
||||
registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Test Hook',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 1000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
process: mockProcess as unknown as import('child_process').ChildProcess,
|
||||
});
|
||||
|
||||
registry.timeout('test-hook-1');
|
||||
|
||||
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
expect(mockProcess.once).toHaveBeenCalledWith(
|
||||
'exit',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call kill if process is already killed', () => {
|
||||
const mockProcess = {
|
||||
killed: true,
|
||||
kill: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
|
||||
registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Test Hook',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 1000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
process: mockProcess as unknown as import('child_process').ChildProcess,
|
||||
});
|
||||
|
||||
registry.timeout('test-hook-1');
|
||||
|
||||
expect(mockProcess.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingHooks', () => {
|
||||
it('should return all pending hooks', () => {
|
||||
registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Hook 1',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
registry.register({
|
||||
hookId: 'test-hook-2',
|
||||
hookName: 'Hook 2',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
const pending = registry.getPendingHooks();
|
||||
expect(pending.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingHooksForSession', () => {
|
||||
it('should return hooks for specific session', () => {
|
||||
registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Hook 1',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
registry.register({
|
||||
hookId: 'test-hook-2',
|
||||
hookName: 'Hook 2',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-2',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
const session1Hooks = registry.getPendingHooksForSession('session-1');
|
||||
expect(session1Hooks.length).toBe(1);
|
||||
expect(session1Hooks[0].hookId).toBe('test-hook-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingOutput', () => {
|
||||
it('should return and clear pending output', () => {
|
||||
registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Test Hook',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: 'plain text output',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
registry.complete('test-hook-1');
|
||||
|
||||
const output1 = registry.getPendingOutput();
|
||||
expect(output1.messages.length).toBe(1);
|
||||
|
||||
// Second call should return empty
|
||||
const output2 = registry.getPendingOutput();
|
||||
expect(output2.messages.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSession', () => {
|
||||
it('should clear all hooks for a session', () => {
|
||||
registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Hook 1',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
registry.register({
|
||||
hookId: 'test-hook-2',
|
||||
hookName: 'Hook 2',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-2',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
registry.clearSession('session-1');
|
||||
|
||||
const pending = registry.getPendingHooks();
|
||||
expect(pending.length).toBe(1);
|
||||
expect(pending[0].sessionId).toBe('session-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkTimeouts', () => {
|
||||
it('should timeout expired hooks', () => {
|
||||
const pastTime = Date.now() - 70000; // 70 seconds ago
|
||||
|
||||
registry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Test Hook',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: pastTime,
|
||||
timeout: 60000, // 60 second timeout
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
registry.checkTimeouts();
|
||||
|
||||
expect(registry.hasRunningHooks()).toBe(false);
|
||||
expect(registry.hasPendingOutput()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrency limits', () => {
|
||||
it('should respect maxConcurrentHooks limit', () => {
|
||||
const limitedRegistry = new AsyncHookRegistry({ maxConcurrentHooks: 2 });
|
||||
|
||||
// Register first hook
|
||||
const id1 = limitedRegistry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Hook 1',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
expect(id1).toBe('test-hook-1');
|
||||
|
||||
// Register second hook
|
||||
const id2 = limitedRegistry.register({
|
||||
hookId: 'test-hook-2',
|
||||
hookName: 'Hook 2',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
expect(id2).toBe('test-hook-2');
|
||||
|
||||
// Third hook should be rejected
|
||||
const id3 = limitedRegistry.register({
|
||||
hookId: 'test-hook-3',
|
||||
hookName: 'Hook 3',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
expect(id3).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow registration after hook completes', () => {
|
||||
const limitedRegistry = new AsyncHookRegistry({ maxConcurrentHooks: 1 });
|
||||
|
||||
// Register first hook
|
||||
limitedRegistry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Hook 1',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
// Second hook should be rejected
|
||||
const id2Before = limitedRegistry.register({
|
||||
hookId: 'test-hook-2',
|
||||
hookName: 'Hook 2',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
expect(id2Before).toBeNull();
|
||||
|
||||
// Complete first hook
|
||||
limitedRegistry.complete('test-hook-1');
|
||||
|
||||
// Now second hook should be accepted
|
||||
const id2After = limitedRegistry.register({
|
||||
hookId: 'test-hook-2',
|
||||
hookName: 'Hook 2',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
expect(id2After).toBe('test-hook-2');
|
||||
});
|
||||
|
||||
it('should report correct running count', () => {
|
||||
const limitedRegistry = new AsyncHookRegistry({ maxConcurrentHooks: 5 });
|
||||
|
||||
expect(limitedRegistry.getRunningCount()).toBe(0);
|
||||
expect(limitedRegistry.canAcceptMore()).toBe(true);
|
||||
|
||||
limitedRegistry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Hook 1',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: Date.now(),
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
expect(limitedRegistry.getRunningCount()).toBe(1);
|
||||
expect(limitedRegistry.canAcceptMore()).toBe(true);
|
||||
|
||||
limitedRegistry.fail('test-hook-1', new Error('test'));
|
||||
|
||||
expect(limitedRegistry.getRunningCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto timeout checker', () => {
|
||||
it('should start and stop timeout checker', () => {
|
||||
const autoRegistry = new AsyncHookRegistry({
|
||||
enableAutoTimeoutCheck: true,
|
||||
timeoutCheckInterval: 100,
|
||||
});
|
||||
|
||||
// Register an expired hook
|
||||
const pastTime = Date.now() - 70000;
|
||||
autoRegistry.register({
|
||||
hookId: 'test-hook-1',
|
||||
hookName: 'Test Hook',
|
||||
hookEvent: HookEventName.PostToolUse,
|
||||
sessionId: 'session-1',
|
||||
startTime: pastTime,
|
||||
timeout: 60000,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
// Stop the checker to prevent interference with other tests
|
||||
autoRegistry.stopTimeoutChecker();
|
||||
|
||||
// Manually check - hook should still be there since we stopped the checker
|
||||
// before it could run
|
||||
expect(autoRegistry.hasRunningHooks()).toBe(true);
|
||||
|
||||
// Now manually trigger timeout check
|
||||
autoRegistry.checkTimeouts();
|
||||
expect(autoRegistry.hasRunningHooks()).toBe(false);
|
||||
});
|
||||
|
||||
it('should stop timeout checker on stopTimeoutChecker call', () => {
|
||||
const autoRegistry = new AsyncHookRegistry({
|
||||
enableAutoTimeoutCheck: true,
|
||||
timeoutCheckInterval: 50,
|
||||
});
|
||||
|
||||
// Stop immediately
|
||||
autoRegistry.stopTimeoutChecker();
|
||||
|
||||
// Should not throw or cause issues
|
||||
expect(() => autoRegistry.stopTimeoutChecker()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
371
packages/core/src/hooks/asyncHookRegistry.ts
Normal file
371
packages/core/src/hooks/asyncHookRegistry.ts
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import type {
|
||||
HookOutput,
|
||||
PendingAsyncHook,
|
||||
AsyncHookOutputMessage,
|
||||
PendingAsyncOutput,
|
||||
} from './types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('ASYNC_HOOK_REGISTRY');
|
||||
|
||||
/**
|
||||
* Default maximum concurrent async hooks
|
||||
*/
|
||||
const DEFAULT_MAX_CONCURRENT_HOOKS = 10;
|
||||
|
||||
/**
|
||||
* Default timeout check interval (5 seconds)
|
||||
*/
|
||||
const DEFAULT_TIMEOUT_CHECK_INTERVAL = 5000;
|
||||
|
||||
/**
|
||||
* Generate a unique hook ID
|
||||
*/
|
||||
export function generateHookId(): string {
|
||||
return `hook_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for AsyncHookRegistry
|
||||
*/
|
||||
export interface AsyncHookRegistryOptions {
|
||||
maxConcurrentHooks?: number;
|
||||
enableAutoTimeoutCheck?: boolean;
|
||||
timeoutCheckInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async Hook Registry - tracks and manages asynchronously executing hooks
|
||||
* with concurrency limits and automatic timeout checking
|
||||
*/
|
||||
export class AsyncHookRegistry {
|
||||
private readonly pendingHooks: Map<string, PendingAsyncHook> = new Map();
|
||||
private readonly completedOutputs: AsyncHookOutputMessage[] = [];
|
||||
private readonly completedContexts: string[] = [];
|
||||
private readonly maxConcurrentHooks: number;
|
||||
private timeoutCheckTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
constructor(options: AsyncHookRegistryOptions = {}) {
|
||||
this.maxConcurrentHooks =
|
||||
options.maxConcurrentHooks ?? DEFAULT_MAX_CONCURRENT_HOOKS;
|
||||
|
||||
// Start automatic timeout checking if enabled
|
||||
if (options.enableAutoTimeoutCheck) {
|
||||
const interval =
|
||||
options.timeoutCheckInterval ?? DEFAULT_TIMEOUT_CHECK_INTERVAL;
|
||||
this.startTimeoutChecker(interval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start automatic timeout checking
|
||||
*/
|
||||
private startTimeoutChecker(interval: number): void {
|
||||
if (this.timeoutCheckTimer) {
|
||||
clearInterval(this.timeoutCheckTimer);
|
||||
}
|
||||
this.timeoutCheckTimer = setInterval(() => {
|
||||
this.checkTimeouts();
|
||||
}, interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop automatic timeout checking
|
||||
*/
|
||||
stopTimeoutChecker(): void {
|
||||
if (this.timeoutCheckTimer) {
|
||||
clearInterval(this.timeoutCheckTimer);
|
||||
this.timeoutCheckTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current number of running hooks
|
||||
*/
|
||||
getRunningCount(): number {
|
||||
return Array.from(this.pendingHooks.values()).filter(
|
||||
(hook) => hook.status === 'running',
|
||||
).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we can accept more async hooks
|
||||
*/
|
||||
canAcceptMore(): boolean {
|
||||
return this.getRunningCount() < this.maxConcurrentHooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new async hook execution
|
||||
* @returns hookId if registered, null if rejected due to concurrency limit
|
||||
*/
|
||||
register(hook: Omit<PendingAsyncHook, 'status'>): string | null {
|
||||
// Check concurrency limit
|
||||
if (!this.canAcceptMore()) {
|
||||
debugLogger.warn(
|
||||
`Async hook registration rejected: concurrency limit reached (${this.maxConcurrentHooks})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hookId = hook.hookId;
|
||||
const pendingHook: PendingAsyncHook = {
|
||||
...hook,
|
||||
status: 'running',
|
||||
};
|
||||
|
||||
this.pendingHooks.set(hookId, pendingHook);
|
||||
debugLogger.debug(
|
||||
`Registered async hook: ${hookId} (${hook.hookName}) for event ${hook.hookEvent} [${this.getRunningCount()}/${this.maxConcurrentHooks}]`,
|
||||
);
|
||||
|
||||
return hookId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hook output (stdout/stderr)
|
||||
*/
|
||||
updateOutput(hookId: string, stdout?: string, stderr?: string): void {
|
||||
const hook = this.pendingHooks.get(hookId);
|
||||
if (hook) {
|
||||
if (stdout !== undefined) {
|
||||
hook.stdout += stdout;
|
||||
}
|
||||
if (stderr !== undefined) {
|
||||
hook.stderr += stderr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a hook as completed with output
|
||||
*/
|
||||
complete(hookId: string, output?: HookOutput): void {
|
||||
const hook = this.pendingHooks.get(hookId);
|
||||
if (!hook) {
|
||||
debugLogger.warn(`Attempted to complete unknown hook: ${hookId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
hook.status = 'completed';
|
||||
hook.output = output;
|
||||
|
||||
// Process output for delivery
|
||||
this.processCompletedOutput(hook);
|
||||
|
||||
// Remove from pending
|
||||
this.pendingHooks.delete(hookId);
|
||||
|
||||
debugLogger.debug(`Async hook completed: ${hookId} (${hook.hookName})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a hook as failed
|
||||
*/
|
||||
fail(hookId: string, error: Error): void {
|
||||
const hook = this.pendingHooks.get(hookId);
|
||||
if (!hook) {
|
||||
debugLogger.warn(`Attempted to fail unknown hook: ${hookId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
hook.status = 'failed';
|
||||
hook.error = error;
|
||||
|
||||
// Add error message to outputs
|
||||
this.completedOutputs.push({
|
||||
type: 'error',
|
||||
message: `Async hook ${hook.hookName} failed: ${error.message}`,
|
||||
hookName: hook.hookName,
|
||||
hookId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Remove from pending
|
||||
this.pendingHooks.delete(hookId);
|
||||
|
||||
debugLogger.debug(`Async hook failed: ${hookId} (${hook.hookName})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a hook as timed out and terminate the process if running
|
||||
*/
|
||||
timeout(hookId: string): void {
|
||||
const hook = this.pendingHooks.get(hookId);
|
||||
if (!hook) {
|
||||
debugLogger.warn(`Attempted to timeout unknown hook: ${hookId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Terminate the process if it's still running
|
||||
if (hook.process && !hook.process.killed) {
|
||||
debugLogger.debug(`Terminating process for timed out hook: ${hookId}`);
|
||||
// First try graceful termination with SIGTERM
|
||||
hook.process.kill('SIGTERM');
|
||||
// Force kill with SIGKILL after 2 seconds if still running
|
||||
const forceKillTimeout = setTimeout(() => {
|
||||
if (hook.process && !hook.process.killed) {
|
||||
debugLogger.debug(`Force killing process for hook: ${hookId}`);
|
||||
hook.process.kill('SIGKILL');
|
||||
}
|
||||
}, 2000);
|
||||
// Clean up the timeout if process exits
|
||||
hook.process.once('exit', () => {
|
||||
clearTimeout(forceKillTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
hook.status = 'timeout';
|
||||
hook.error = new Error(`Hook timed out after ${hook.timeout}ms`);
|
||||
|
||||
// Add timeout message to outputs
|
||||
this.completedOutputs.push({
|
||||
type: 'warning',
|
||||
message: `Async hook ${hook.hookName} timed out after ${hook.timeout}ms`,
|
||||
hookName: hook.hookName,
|
||||
hookId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Remove from pending
|
||||
this.pendingHooks.delete(hookId);
|
||||
|
||||
debugLogger.debug(`Async hook timed out: ${hookId} (${hook.hookName})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending hooks
|
||||
*/
|
||||
getPendingHooks(): PendingAsyncHook[] {
|
||||
return Array.from(this.pendingHooks.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending hooks for a specific session
|
||||
*/
|
||||
getPendingHooksForSession(sessionId: string): PendingAsyncHook[] {
|
||||
return Array.from(this.pendingHooks.values()).filter(
|
||||
(hook) => hook.sessionId === sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and clear pending output for delivery to the next turn
|
||||
*/
|
||||
getPendingOutput(): PendingAsyncOutput {
|
||||
const output: PendingAsyncOutput = {
|
||||
messages: [...this.completedOutputs],
|
||||
contexts: [...this.completedContexts],
|
||||
};
|
||||
|
||||
// Clear after retrieval
|
||||
this.completedOutputs.length = 0;
|
||||
this.completedContexts.length = 0;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any pending outputs
|
||||
*/
|
||||
hasPendingOutput(): boolean {
|
||||
return (
|
||||
this.completedOutputs.length > 0 || this.completedContexts.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any running hooks
|
||||
*/
|
||||
hasRunningHooks(): boolean {
|
||||
return this.pendingHooks.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for timed out hooks and mark them
|
||||
*/
|
||||
checkTimeouts(): void {
|
||||
const now = Date.now();
|
||||
for (const [hookId, hook] of this.pendingHooks.entries()) {
|
||||
if (hook.status === 'running' && now - hook.startTime > hook.timeout) {
|
||||
this.timeout(hookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending hooks for a session (e.g., on session end)
|
||||
*/
|
||||
clearSession(sessionId: string): void {
|
||||
for (const [hookId, hook] of this.pendingHooks.entries()) {
|
||||
if (hook.sessionId === sessionId) {
|
||||
this.pendingHooks.delete(hookId);
|
||||
debugLogger.debug(
|
||||
`Cleared async hook on session end: ${hookId} (${hook.hookName})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process completed hook output for delivery
|
||||
*/
|
||||
private processCompletedOutput(hook: PendingAsyncHook): void {
|
||||
// Parse stdout for JSON output
|
||||
if (hook.stdout) {
|
||||
try {
|
||||
const parsed = JSON.parse(hook.stdout.trim());
|
||||
|
||||
// Extract system message
|
||||
if (parsed.systemMessage && typeof parsed.systemMessage === 'string') {
|
||||
this.completedOutputs.push({
|
||||
type: 'system',
|
||||
message: parsed.systemMessage,
|
||||
hookName: hook.hookName,
|
||||
hookId: hook.hookId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Extract additional context
|
||||
if (
|
||||
parsed.hookSpecificOutput?.additionalContext &&
|
||||
typeof parsed.hookSpecificOutput.additionalContext === 'string'
|
||||
) {
|
||||
this.completedContexts.push(
|
||||
parsed.hookSpecificOutput.additionalContext,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, treat as plain text message if non-empty
|
||||
const trimmed = hook.stdout.trim();
|
||||
if (trimmed) {
|
||||
this.completedOutputs.push({
|
||||
type: 'info',
|
||||
message: trimmed,
|
||||
hookName: hook.hookName,
|
||||
hookId: hook.hookId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add stderr as warning if present
|
||||
if (hook.stderr && hook.stderr.trim()) {
|
||||
this.completedOutputs.push({
|
||||
type: 'warning',
|
||||
message: hook.stderr.trim(),
|
||||
hookName: hook.hookName,
|
||||
hookId: hook.hookId,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
91
packages/core/src/hooks/combinedAbortSignal.test.ts
Normal file
91
packages/core/src/hooks/combinedAbortSignal.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createCombinedAbortSignal } from './combinedAbortSignal.js';
|
||||
|
||||
describe('createCombinedAbortSignal', () => {
|
||||
it('should return a non-aborted signal by default', () => {
|
||||
const { signal, cleanup } = createCombinedAbortSignal();
|
||||
expect(signal.aborted).toBe(false);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should abort after timeout', async () => {
|
||||
const { signal, cleanup } = createCombinedAbortSignal(undefined, {
|
||||
timeoutMs: 50,
|
||||
});
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(signal.aborted).toBe(true);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should abort when external signal is aborted', () => {
|
||||
const externalController = new AbortController();
|
||||
const { signal, cleanup } = createCombinedAbortSignal(
|
||||
externalController.signal,
|
||||
);
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
externalController.abort();
|
||||
expect(signal.aborted).toBe(true);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should abort immediately if external signal is already aborted', () => {
|
||||
const externalController = new AbortController();
|
||||
externalController.abort();
|
||||
|
||||
const { signal, cleanup } = createCombinedAbortSignal(
|
||||
externalController.signal,
|
||||
);
|
||||
expect(signal.aborted).toBe(true);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should cleanup timeout timer', async () => {
|
||||
const { signal, cleanup } = createCombinedAbortSignal(undefined, {
|
||||
timeoutMs: 50,
|
||||
});
|
||||
|
||||
cleanup();
|
||||
|
||||
// Wait longer than timeout - should not abort because timer was cleared
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('should work with both external signal and timeout', async () => {
|
||||
const externalController = new AbortController();
|
||||
const { signal, cleanup } = createCombinedAbortSignal(
|
||||
externalController.signal,
|
||||
{ timeoutMs: 200 },
|
||||
);
|
||||
|
||||
// Abort external signal before timeout
|
||||
externalController.abort();
|
||||
expect(signal.aborted).toBe(true);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should timeout before external signal', async () => {
|
||||
const externalController = new AbortController();
|
||||
const { signal, cleanup } = createCombinedAbortSignal(
|
||||
externalController.signal,
|
||||
{ timeoutMs: 50 },
|
||||
);
|
||||
|
||||
// Wait for timeout
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(signal.aborted).toBe(true);
|
||||
|
||||
// External signal is still not aborted
|
||||
expect(externalController.signal.aborted).toBe(false);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
52
packages/core/src/hooks/combinedAbortSignal.ts
Normal file
52
packages/core/src/hooks/combinedAbortSignal.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a combined AbortSignal that aborts when either:
|
||||
* - The provided external signal is aborted, OR
|
||||
* - The timeout is reached
|
||||
*
|
||||
* @param externalSignal - Optional external AbortSignal to combine
|
||||
* @param timeoutMs - Timeout in milliseconds
|
||||
* @returns Object containing the combined signal and a cleanup function
|
||||
*/
|
||||
export function createCombinedAbortSignal(
|
||||
externalSignal?: AbortSignal,
|
||||
options?: { timeoutMs?: number },
|
||||
): { signal: AbortSignal; cleanup: () => void } {
|
||||
const controller = new AbortController();
|
||||
|
||||
const timeoutMs = options?.timeoutMs;
|
||||
|
||||
// Set up timeout
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
if (timeoutMs !== undefined && timeoutMs > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
// Listen to external signal
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
const abortHandler = () => {
|
||||
controller.abort();
|
||||
};
|
||||
externalSignal.addEventListener('abort', abortHandler, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return { signal: controller.signal, cleanup };
|
||||
}
|
||||
197
packages/core/src/hooks/envInterpolator.test.ts
Normal file
197
packages/core/src/hooks/envInterpolator.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
interpolateEnvVars,
|
||||
interpolateHeaders,
|
||||
interpolateUrl,
|
||||
hasEnvVarReferences,
|
||||
extractEnvVarNames,
|
||||
sanitizeHeaderValue,
|
||||
} from './envInterpolator.js';
|
||||
|
||||
describe('envInterpolator', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
process.env['MY_TOKEN'] = 'secret-token';
|
||||
process.env['API_KEY'] = 'api-key-123';
|
||||
process.env['EMPTY_VAR'] = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('interpolateEnvVars', () => {
|
||||
it('should replace allowed environment variables with $VAR syntax', () => {
|
||||
const result = interpolateEnvVars('Bearer $MY_TOKEN', ['MY_TOKEN']);
|
||||
expect(result).toBe('Bearer secret-token');
|
||||
});
|
||||
|
||||
it('should replace allowed environment variables with ${VAR} syntax', () => {
|
||||
const result = interpolateEnvVars('Bearer ${MY_TOKEN}', ['MY_TOKEN']);
|
||||
expect(result).toBe('Bearer secret-token');
|
||||
});
|
||||
|
||||
it('should replace variables not in whitelist with empty string', () => {
|
||||
const result = interpolateEnvVars('Bearer $OTHER_VAR', ['MY_TOKEN']);
|
||||
expect(result).toBe('Bearer ');
|
||||
});
|
||||
|
||||
it('should handle multiple variables', () => {
|
||||
const result = interpolateEnvVars('$MY_TOKEN:$API_KEY', [
|
||||
'MY_TOKEN',
|
||||
'API_KEY',
|
||||
]);
|
||||
expect(result).toBe('secret-token:api-key-123');
|
||||
});
|
||||
|
||||
it('should handle mixed allowed and disallowed variables', () => {
|
||||
const result = interpolateEnvVars('$MY_TOKEN:$OTHER_VAR', ['MY_TOKEN']);
|
||||
expect(result).toBe('secret-token:');
|
||||
});
|
||||
|
||||
it('should handle undefined environment variables', () => {
|
||||
const result = interpolateEnvVars('$UNDEFINED_VAR', ['UNDEFINED_VAR']);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty whitelist', () => {
|
||||
const result = interpolateEnvVars('$MY_TOKEN', []);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should not replace text without $ prefix', () => {
|
||||
const result = interpolateEnvVars('MY_TOKEN', ['MY_TOKEN']);
|
||||
expect(result).toBe('MY_TOKEN');
|
||||
});
|
||||
|
||||
it('should sanitize CR characters to prevent header injection', () => {
|
||||
process.env['EVIL_TOKEN'] = 'good\r\nX-Evil: injected';
|
||||
const result = interpolateEnvVars('$EVIL_TOKEN', ['EVIL_TOKEN']);
|
||||
expect(result).toBe('goodX-Evil: injected');
|
||||
});
|
||||
|
||||
it('should sanitize LF characters to prevent header injection', () => {
|
||||
process.env['EVIL_TOKEN'] = 'good\nX-Evil: injected';
|
||||
const result = interpolateEnvVars('$EVIL_TOKEN', ['EVIL_TOKEN']);
|
||||
expect(result).toBe('goodX-Evil: injected');
|
||||
});
|
||||
|
||||
it('should sanitize NUL characters', () => {
|
||||
process.env['EVIL_TOKEN'] = 'good\x00bad';
|
||||
const result = interpolateEnvVars('$EVIL_TOKEN', ['EVIL_TOKEN']);
|
||||
expect(result).toBe('goodbad');
|
||||
});
|
||||
|
||||
it('should sanitize CRLF and NUL combined', () => {
|
||||
process.env['EVIL_TOKEN'] = 'token\r\nX-Injected: 1\x00more';
|
||||
const result = interpolateEnvVars('Bearer $EVIL_TOKEN', ['EVIL_TOKEN']);
|
||||
expect(result).toBe('Bearer tokenX-Injected: 1more');
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolateHeaders', () => {
|
||||
it('should interpolate all header values', () => {
|
||||
const headers = {
|
||||
Authorization: 'Bearer $MY_TOKEN',
|
||||
'X-API-Key': '$API_KEY',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const result = interpolateHeaders(headers, ['MY_TOKEN', 'API_KEY']);
|
||||
expect(result).toEqual({
|
||||
Authorization: 'Bearer secret-token',
|
||||
'X-API-Key': 'api-key-123',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty headers', () => {
|
||||
const result = interpolateHeaders({}, ['MY_TOKEN']);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolateUrl', () => {
|
||||
it('should interpolate URL with environment variables', () => {
|
||||
process.env['API_HOST'] = 'api.example.com';
|
||||
const result = interpolateUrl('https://$API_HOST/v1/hook', ['API_HOST']);
|
||||
expect(result).toBe('https://api.example.com/v1/hook');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasEnvVarReferences', () => {
|
||||
it('should return true for $VAR syntax', () => {
|
||||
expect(hasEnvVarReferences('$MY_TOKEN')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for ${VAR} syntax', () => {
|
||||
expect(hasEnvVarReferences('${MY_TOKEN}')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for plain text', () => {
|
||||
expect(hasEnvVarReferences('plain text')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(hasEnvVarReferences('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractEnvVarNames', () => {
|
||||
it('should extract single variable name', () => {
|
||||
expect(extractEnvVarNames('$MY_TOKEN')).toEqual(['MY_TOKEN']);
|
||||
});
|
||||
|
||||
it('should extract multiple variable names', () => {
|
||||
expect(extractEnvVarNames('$MY_TOKEN:$API_KEY')).toEqual([
|
||||
'MY_TOKEN',
|
||||
'API_KEY',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract from ${VAR} syntax', () => {
|
||||
expect(extractEnvVarNames('${MY_TOKEN}')).toEqual(['MY_TOKEN']);
|
||||
});
|
||||
|
||||
it('should not duplicate variable names', () => {
|
||||
expect(extractEnvVarNames('$MY_TOKEN:$MY_TOKEN')).toEqual(['MY_TOKEN']);
|
||||
});
|
||||
|
||||
it('should return empty array for no variables', () => {
|
||||
expect(extractEnvVarNames('plain text')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeHeaderValue', () => {
|
||||
it('should strip CR characters', () => {
|
||||
expect(sanitizeHeaderValue('token\r\nX-Evil: 1')).toBe('tokenX-Evil: 1');
|
||||
});
|
||||
|
||||
it('should strip LF characters', () => {
|
||||
expect(sanitizeHeaderValue('token\nX-Evil: 1')).toBe('tokenX-Evil: 1');
|
||||
});
|
||||
|
||||
it('should strip NUL characters', () => {
|
||||
expect(sanitizeHeaderValue('good\x00bad')).toBe('goodbad');
|
||||
});
|
||||
|
||||
it('should strip all three dangerous characters', () => {
|
||||
expect(sanitizeHeaderValue('a\r\nb\x00c')).toBe('abc');
|
||||
});
|
||||
|
||||
it('should not affect safe values', () => {
|
||||
expect(sanitizeHeaderValue('Bearer abc123')).toBe('Bearer abc123');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(sanitizeHeaderValue('')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
133
packages/core/src/hooks/envInterpolator.ts
Normal file
133
packages/core/src/hooks/envInterpolator.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Environment variable interpolation utilities for HTTP hooks.
|
||||
* Provides secure interpolation with whitelist-based access control.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strip CR, LF, and NUL bytes from a header value to prevent HTTP header
|
||||
* injection (CRLF injection) via env var values or hook-configured header
|
||||
* templates. A malicious env var like "token\r\nX-Evil: 1" would otherwise
|
||||
* inject a second header into the request.
|
||||
*
|
||||
* Aligned with Claude Code's sanitizeHeaderValue behavior.
|
||||
*/
|
||||
export function sanitizeHeaderValue(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return value.replace(/[\r\n\x00]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate environment variables in a string value.
|
||||
* Only variables in the allowedVars list will be replaced.
|
||||
* Variables not in the whitelist will be replaced with empty string.
|
||||
*
|
||||
* Supports both $VAR_NAME and ${VAR_NAME} syntax.
|
||||
*
|
||||
* @param value - The string containing environment variable references
|
||||
* @param allowedVars - List of allowed environment variable names
|
||||
* @returns The interpolated string (sanitized to prevent header injection)
|
||||
*/
|
||||
/**
|
||||
* Dangerous variable names that could be used for prototype pollution attacks
|
||||
*/
|
||||
const DANGEROUS_VAR_NAMES = [
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'prototype',
|
||||
'__defineGetter__',
|
||||
'__defineSetter__',
|
||||
'__lookupGetter__',
|
||||
'__lookupSetter__',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a variable name is safe (not a prototype pollution vector)
|
||||
*/
|
||||
function isSafeVarName(varName: string): boolean {
|
||||
return !DANGEROUS_VAR_NAMES.includes(varName);
|
||||
}
|
||||
|
||||
export function interpolateEnvVars(
|
||||
value: string,
|
||||
allowedVars: string[],
|
||||
): string {
|
||||
// Match $VAR_NAME or ${VAR_NAME}
|
||||
const interpolated = value.replace(
|
||||
/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g,
|
||||
(match, varName: string) => {
|
||||
// Block dangerous variable names to prevent prototype pollution
|
||||
if (!isSafeVarName(varName)) {
|
||||
return '';
|
||||
}
|
||||
if (allowedVars.includes(varName)) {
|
||||
return process.env[varName] || '';
|
||||
}
|
||||
// Not in whitelist, replace with empty string for security
|
||||
return '';
|
||||
},
|
||||
);
|
||||
// Sanitize to prevent CRLF/NUL header injection
|
||||
return sanitizeHeaderValue(interpolated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate environment variables in all header values.
|
||||
*
|
||||
* @param headers - Record of header name to value
|
||||
* @param allowedVars - List of allowed environment variable names
|
||||
* @returns New headers record with interpolated values
|
||||
*/
|
||||
export function interpolateHeaders(
|
||||
headers: Record<string, string>,
|
||||
allowedVars: string[],
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
result[key] = interpolateEnvVars(value, allowedVars);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate environment variables in a URL.
|
||||
*
|
||||
* @param url - The URL string containing environment variable references
|
||||
* @param allowedVars - List of allowed environment variable names
|
||||
* @returns The interpolated URL
|
||||
*/
|
||||
export function interpolateUrl(url: string, allowedVars: string[]): string {
|
||||
return interpolateEnvVars(url, allowedVars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string contains environment variable references.
|
||||
*
|
||||
* @param value - The string to check
|
||||
* @returns True if the string contains env var references
|
||||
*/
|
||||
export function hasEnvVarReferences(value: string): boolean {
|
||||
return /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all environment variable names referenced in a string.
|
||||
*
|
||||
* @param value - The string to extract from
|
||||
* @returns Array of environment variable names
|
||||
*/
|
||||
export function extractEnvVarNames(value: string): string[] {
|
||||
const matches = value.matchAll(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g);
|
||||
const names: string[] = [];
|
||||
for (const match of matches) {
|
||||
if (match[1] && !names.includes(match[1])) {
|
||||
names.push(match[1]);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
432
packages/core/src/hooks/functionHookRunner.test.ts
Normal file
432
packages/core/src/hooks/functionHookRunner.test.ts
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FunctionHookRunner } from './functionHookRunner.js';
|
||||
import { HookEventName, HookType } from './types.js';
|
||||
import type { FunctionHookConfig, HookInput, HookOutput } from './types.js';
|
||||
|
||||
describe('FunctionHookRunner', () => {
|
||||
let functionRunner: FunctionHookRunner;
|
||||
|
||||
beforeEach(() => {
|
||||
functionRunner = new FunctionHookRunner();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockInput = (overrides: Partial<HookInput> = {}): HookInput => ({
|
||||
session_id: 'test-session',
|
||||
transcript_path: '/test/transcript',
|
||||
cwd: '/test',
|
||||
hook_event_name: 'PreToolUse',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockConfig = (
|
||||
callback: FunctionHookConfig['callback'],
|
||||
overrides: Partial<FunctionHookConfig> = {},
|
||||
): FunctionHookConfig => ({
|
||||
type: HookType.Function,
|
||||
callback,
|
||||
errorMessage: 'Hook failed',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute callback successfully', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
decision: 'allow',
|
||||
reason: 'Approved',
|
||||
} as HookOutput);
|
||||
|
||||
const config = createMockConfig(mockCallback);
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outcome).toBe('success');
|
||||
expect(result.output?.decision).toBe('allow');
|
||||
expect(mockCallback).toHaveBeenCalledWith(input, undefined);
|
||||
});
|
||||
|
||||
it('should handle callback returning undefined', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const config = createMockConfig(mockCallback);
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toEqual({ continue: true });
|
||||
});
|
||||
|
||||
it('should handle callback throwing error', async () => {
|
||||
const mockCallback = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Callback error'));
|
||||
|
||||
const config = createMockConfig(mockCallback, {
|
||||
errorMessage: 'Custom error message',
|
||||
});
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain('Custom error message');
|
||||
expect(result.error?.message).toContain('Callback error');
|
||||
});
|
||||
|
||||
it('should handle timeout', async () => {
|
||||
const mockCallback = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ continue: true }), 1000);
|
||||
}),
|
||||
);
|
||||
|
||||
const config = createMockConfig(mockCallback, { timeout: 10 });
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain('timed out');
|
||||
});
|
||||
|
||||
it('should handle abort signal', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const mockCallback = vi.fn().mockResolvedValue({ continue: true });
|
||||
const config = createMockConfig(mockCallback);
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain('cancelled');
|
||||
expect(mockCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass correct input to callback', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
const config = createMockConfig(mockCallback);
|
||||
const input = createMockInput({
|
||||
session_id: 'custom-session',
|
||||
cwd: '/custom/path',
|
||||
});
|
||||
|
||||
await functionRunner.execute(config, HookEventName.PreToolUse, input);
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
session_id: 'custom-session',
|
||||
cwd: '/custom/path',
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should include hook id in result', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
const config = createMockConfig(mockCallback, {
|
||||
id: 'my-hook-id',
|
||||
name: 'My Hook',
|
||||
});
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.hookConfig).toEqual(config);
|
||||
});
|
||||
|
||||
it('should reject invalid callback', async () => {
|
||||
const config = createMockConfig(
|
||||
'not a function' as unknown as FunctionHookConfig['callback'],
|
||||
);
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain('Invalid callback');
|
||||
});
|
||||
|
||||
it('should handle abort signal during execution', async () => {
|
||||
const controller = new AbortController();
|
||||
const mockCallback = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
// Abort after a short delay
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 10);
|
||||
// Resolve after a longer delay
|
||||
setTimeout(() => resolve({ continue: true }), 100);
|
||||
}),
|
||||
);
|
||||
|
||||
const config = createMockConfig(mockCallback, { timeout: 5000 });
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain('aborted');
|
||||
});
|
||||
|
||||
it('should properly clean up resources on success', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
const config = createMockConfig(mockCallback, { timeout: 5000 });
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// No timeout should fire after success
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should support boolean semantics (true=success)', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue(true);
|
||||
const config = createMockConfig(mockCallback);
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outcome).toBe('success');
|
||||
expect(result.output).toEqual({ continue: true });
|
||||
});
|
||||
|
||||
it('should support boolean semantics (false=blocking)', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue(false);
|
||||
const config = createMockConfig(mockCallback, {
|
||||
errorMessage: 'Validation failed',
|
||||
});
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.outcome).toBe('blocking');
|
||||
expect(result.output?.continue).toBe(false);
|
||||
expect(result.output?.decision).toBe('block');
|
||||
expect(result.output?.reason).toBe('Validation failed');
|
||||
});
|
||||
|
||||
it('should pass context to callback', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue(true);
|
||||
const config = createMockConfig(mockCallback);
|
||||
const input = createMockInput();
|
||||
const messages = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
];
|
||||
|
||||
await functionRunner.execute(config, HookEventName.PreToolUse, input, {
|
||||
messages,
|
||||
toolUseID: 'tool-123',
|
||||
});
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
session_id: 'test-session',
|
||||
cwd: '/test',
|
||||
}),
|
||||
{
|
||||
messages,
|
||||
toolUseID: 'tool-123',
|
||||
signal: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onHookSuccess callback on success', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue(true);
|
||||
const onSuccess = vi.fn();
|
||||
const config = createMockConfig(mockCallback, {
|
||||
onHookSuccess: onSuccess,
|
||||
});
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(onSuccess).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
it('should not call onHookSuccess on failure', async () => {
|
||||
const mockCallback = vi.fn().mockRejectedValue(new Error('Test error'));
|
||||
const onSuccess = vi.fn();
|
||||
const config = createMockConfig(mockCallback, {
|
||||
errorMessage: 'Hook failed',
|
||||
onHookSuccess: onSuccess,
|
||||
});
|
||||
const input = createMockInput();
|
||||
|
||||
await functionRunner.execute(config, HookEventName.PreToolUse, input);
|
||||
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle onHookSuccess error gracefully', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue(true);
|
||||
const onSuccess = vi.fn().mockImplementation(() => {
|
||||
throw new Error('Success callback error');
|
||||
});
|
||||
const config = createMockConfig(mockCallback, {
|
||||
onHookSuccess: onSuccess,
|
||||
});
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should determine outcome from HookOutput decision', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
decision: 'block',
|
||||
reason: 'Security violation',
|
||||
});
|
||||
const config = createMockConfig(mockCallback);
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.outcome).toBe('blocking');
|
||||
expect(result.output?.decision).toBe('block');
|
||||
});
|
||||
|
||||
it('should determine outcome from HookOutput continue=false', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
continue: false,
|
||||
stopReason: 'Please stop',
|
||||
});
|
||||
const config = createMockConfig(mockCallback);
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.outcome).toBe('blocking');
|
||||
expect(result.output?.continue).toBe(false);
|
||||
});
|
||||
|
||||
it('should treat undefined return as success', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue(undefined);
|
||||
const config = createMockConfig(mockCallback);
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outcome).toBe('success');
|
||||
expect(result.output).toEqual({ continue: true });
|
||||
});
|
||||
|
||||
it('should handle async callback with context', async () => {
|
||||
const mockCallback = vi
|
||||
.fn()
|
||||
.mockImplementation(async (_input, context) => {
|
||||
expect(context).toBeDefined();
|
||||
expect(context?.messages).toEqual([{ role: 'user' }]);
|
||||
return true;
|
||||
});
|
||||
|
||||
const config = createMockConfig(mockCallback);
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await functionRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
{ messages: [{ role: 'user' }] },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
257
packages/core/src/hooks/functionHookRunner.ts
Normal file
257
packages/core/src/hooks/functionHookRunner.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import type {
|
||||
FunctionHookConfig,
|
||||
HookInput,
|
||||
HookOutput,
|
||||
HookExecutionResult,
|
||||
HookEventName,
|
||||
FunctionHookContext,
|
||||
HookExecutionOutcome,
|
||||
} from './types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('FUNCTION_HOOK_RUNNER');
|
||||
|
||||
/**
|
||||
* Default timeout for function hook execution (5 seconds)
|
||||
* Function hooks are intended for quick validation checks
|
||||
*/
|
||||
const DEFAULT_FUNCTION_TIMEOUT = 5000;
|
||||
|
||||
/**
|
||||
* Function Hook Runner - executes function hooks (callbacks)
|
||||
* Used primarily for Session Hooks registered via SDK
|
||||
*/
|
||||
export class FunctionHookRunner {
|
||||
/**
|
||||
* Execute a function hook
|
||||
* @param hookConfig Function hook configuration
|
||||
* @param eventName Event name
|
||||
* @param input Hook input
|
||||
* @param context Optional context (messages, toolUseID, signal)
|
||||
*/
|
||||
async execute(
|
||||
hookConfig: FunctionHookConfig,
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
context?: FunctionHookContext,
|
||||
): Promise<HookExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
const hookId = hookConfig.id || hookConfig.name || 'anonymous-function';
|
||||
const signal = context?.signal;
|
||||
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
outcome: 'cancelled',
|
||||
error: new Error(
|
||||
`Function hook execution cancelled (aborted): ${hookId}`,
|
||||
),
|
||||
duration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const timeout = hookConfig.timeout ?? DEFAULT_FUNCTION_TIMEOUT;
|
||||
|
||||
// Execute callback with timeout and context
|
||||
const result = await this.executeWithTimeout(
|
||||
hookConfig.callback,
|
||||
input,
|
||||
context,
|
||||
timeout,
|
||||
signal,
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
debugLogger.debug(
|
||||
`Function hook ${hookId} completed successfully in ${duration}ms`,
|
||||
);
|
||||
|
||||
// Process the callback result
|
||||
const executionResult = this.processHookResult(
|
||||
hookConfig,
|
||||
eventName,
|
||||
result,
|
||||
duration,
|
||||
);
|
||||
|
||||
// Invoke success callback if provided
|
||||
if (executionResult.success && hookConfig.onHookSuccess) {
|
||||
try {
|
||||
hookConfig.onHookSuccess(executionResult);
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`onHookSuccess callback failed for ${hookId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return executionResult;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
debugLogger.warn(`Function hook ${hookId} failed: ${errorMessage}`);
|
||||
|
||||
// Use configured error message if available
|
||||
const displayError = hookConfig.errorMessage
|
||||
? new Error(`${hookConfig.errorMessage}: ${errorMessage}`)
|
||||
: error instanceof Error
|
||||
? error
|
||||
: new Error(errorMessage);
|
||||
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
outcome: 'non_blocking_error',
|
||||
error: displayError,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process hook result and convert to execution result
|
||||
*/
|
||||
private processHookResult(
|
||||
hookConfig: FunctionHookConfig,
|
||||
eventName: HookEventName,
|
||||
result: HookOutput | boolean | undefined,
|
||||
duration: number,
|
||||
): HookExecutionResult {
|
||||
// Boolean semantics: true=success, false=blocking
|
||||
if (typeof result === 'boolean') {
|
||||
if (result) {
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: true,
|
||||
outcome: 'success',
|
||||
output: { continue: true },
|
||||
duration,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
outcome: 'blocking',
|
||||
output: {
|
||||
continue: false,
|
||||
stopReason: hookConfig.errorMessage || 'Blocked by function hook',
|
||||
decision: 'block',
|
||||
reason: hookConfig.errorMessage || 'Blocked by function hook',
|
||||
},
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// HookOutput semantics (advanced)
|
||||
const output = result || { continue: true };
|
||||
const outcome: HookExecutionOutcome = this.determineOutcome(output);
|
||||
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: outcome === 'success',
|
||||
outcome,
|
||||
output,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine outcome from HookOutput
|
||||
*/
|
||||
private determineOutcome(output: HookOutput): HookExecutionOutcome {
|
||||
if (output.decision === 'block' || output.decision === 'deny') {
|
||||
return 'blocking';
|
||||
}
|
||||
if (output.continue === false) {
|
||||
return 'blocking';
|
||||
}
|
||||
return 'success';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute callback with timeout support using Promise.race for proper race condition handling
|
||||
*/
|
||||
private async executeWithTimeout(
|
||||
callback: FunctionHookConfig['callback'],
|
||||
input: HookInput,
|
||||
context: FunctionHookContext | undefined,
|
||||
timeout: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HookOutput | boolean | undefined> {
|
||||
// Validate callback
|
||||
if (typeof callback !== 'function') {
|
||||
throw new Error('Invalid callback: expected a function');
|
||||
}
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
let abortHandler: (() => void) | undefined;
|
||||
|
||||
// Cleanup function to ensure all resources are released
|
||||
const cleanup = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
}
|
||||
if (signal && abortHandler) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
abortHandler = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Create timeout promise
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`Function hook timed out after ${timeout}ms`));
|
||||
}, timeout);
|
||||
});
|
||||
|
||||
// Create abort promise
|
||||
const abortPromise = new Promise<never>((_, reject) => {
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
reject(new Error('Function hook execution aborted'));
|
||||
return;
|
||||
}
|
||||
abortHandler = () => {
|
||||
reject(new Error('Function hook execution aborted'));
|
||||
};
|
||||
signal.addEventListener('abort', abortHandler);
|
||||
}
|
||||
});
|
||||
|
||||
// Race between callback execution, timeout, and abort
|
||||
const promises: Array<Promise<HookOutput | boolean | undefined | never>> =
|
||||
[callback(input, context), timeoutPromise];
|
||||
|
||||
if (signal) {
|
||||
promises.push(abortPromise);
|
||||
}
|
||||
|
||||
const result = await Promise.race(promises);
|
||||
cleanup();
|
||||
return result;
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import type {
|
|||
HookRunner,
|
||||
HookAggregator,
|
||||
AggregatedHookResult,
|
||||
SessionHooksManager,
|
||||
} from './index.js';
|
||||
import type { HookConfig, HookOutput, PermissionSuggestion } from './types.js';
|
||||
import type { HookExecutionResult } from './types.js';
|
||||
|
|
@ -40,6 +41,7 @@ describe('HookEventHandler', () => {
|
|||
let mockHookPlanner: HookPlanner;
|
||||
let mockHookRunner: HookRunner;
|
||||
let mockHookAggregator: HookAggregator;
|
||||
let mockSessionHooksManager: SessionHooksManager;
|
||||
let hookEventHandler: HookEventHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -62,11 +64,26 @@ describe('HookEventHandler', () => {
|
|||
aggregateResults: vi.fn(),
|
||||
} as unknown as HookAggregator;
|
||||
|
||||
mockSessionHooksManager = {
|
||||
getMatchingHooks: vi.fn().mockReturnValue([]),
|
||||
getHooksForEvent: vi.fn().mockReturnValue([]),
|
||||
hasSessionHooks: vi.fn().mockReturnValue(false),
|
||||
addSessionHook: vi.fn(),
|
||||
addFunctionHook: vi.fn(),
|
||||
removeHook: vi.fn(),
|
||||
removeFunctionHook: vi.fn(),
|
||||
clearSessionHooks: vi.fn(),
|
||||
getActiveSessions: vi.fn().mockReturnValue([]),
|
||||
getHookCount: vi.fn().mockReturnValue(0),
|
||||
getAllSessionHooks: vi.fn().mockReturnValue([]),
|
||||
} as unknown as SessionHooksManager;
|
||||
|
||||
hookEventHandler = new HookEventHandler(
|
||||
mockConfig,
|
||||
mockHookPlanner,
|
||||
mockHookRunner,
|
||||
mockHookAggregator,
|
||||
mockSessionHooksManager,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -722,6 +739,10 @@ describe('HookEventHandler', () => {
|
|||
expect.any(Function), // onHookStart callback
|
||||
expect.any(Function), // onHookEnd callback
|
||||
undefined, // signal
|
||||
expect.objectContaining({
|
||||
messages: undefined,
|
||||
toolUseID: 'toolu_test111',
|
||||
}), // functionContext
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -2946,4 +2967,134 @@ describe('HookEventHandler', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessagesProvider integration', () => {
|
||||
it('should accept messagesProvider in constructor', () => {
|
||||
const messagesProvider = vi
|
||||
.fn()
|
||||
.mockReturnValue([{ role: 'user', content: 'Hello' }]);
|
||||
|
||||
const handler = new HookEventHandler(
|
||||
mockConfig,
|
||||
mockHookPlanner,
|
||||
mockHookRunner,
|
||||
mockHookAggregator,
|
||||
mockSessionHooksManager,
|
||||
messagesProvider,
|
||||
);
|
||||
|
||||
expect(handler.getMessagesProvider()).toBe(messagesProvider);
|
||||
});
|
||||
|
||||
it('should set messagesProvider via setMessagesProvider', () => {
|
||||
hookEventHandler.setMessagesProvider(vi.fn().mockReturnValue([]));
|
||||
expect(hookEventHandler.getMessagesProvider()).toBeDefined();
|
||||
});
|
||||
|
||||
it('should pass messages to function hooks via context', async () => {
|
||||
const messages = [{ role: 'user', content: 'Test message' }];
|
||||
const messagesProvider = vi.fn().mockReturnValue(messages);
|
||||
|
||||
hookEventHandler.setMessagesProvider(messagesProvider);
|
||||
|
||||
const mockPlan = createMockExecutionPlan([
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo test',
|
||||
source: HooksConfigSource.Project,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
|
||||
|
||||
await hookEventHandler.firePreToolUseEvent(
|
||||
'Bash',
|
||||
{ command: 'ls' },
|
||||
'toolu_test',
|
||||
PermissionMode.Default,
|
||||
);
|
||||
|
||||
// Verify context was passed with messages
|
||||
expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
HookEventName.PreToolUse,
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
messages,
|
||||
toolUseID: 'toolu_test',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass toolUseID from input to context', async () => {
|
||||
const mockPlan = createMockExecutionPlan([
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo test',
|
||||
source: HooksConfigSource.Project,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
|
||||
|
||||
await hookEventHandler.firePostToolUseEvent(
|
||||
'Write',
|
||||
{ file_path: '/test.txt' },
|
||||
{ content: 'test' },
|
||||
'toolu_12345',
|
||||
PermissionMode.Default,
|
||||
);
|
||||
|
||||
expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
HookEventName.PostToolUse,
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
toolUseID: 'toolu_12345',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined messagesProvider', async () => {
|
||||
// No messagesProvider set
|
||||
const mockPlan = createMockExecutionPlan([
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo test',
|
||||
source: HooksConfigSource.Project,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
|
||||
|
||||
await hookEventHandler.firePreToolUseEvent(
|
||||
'Bash',
|
||||
{ command: 'ls' },
|
||||
'toolu_test',
|
||||
PermissionMode.Default,
|
||||
);
|
||||
|
||||
expect(mockHookRunner.executeHooksParallel).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
HookEventName.PreToolUse,
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
messages: undefined,
|
||||
toolUseID: 'toolu_test',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { Config } from '../config/config.js';
|
|||
import type { HookPlanner, HookEventContext } from './hookPlanner.js';
|
||||
import type { HookRunner } from './hookRunner.js';
|
||||
import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js';
|
||||
import type { SessionHooksManager } from './sessionHooksManager.js';
|
||||
import { HookEventName } from './types.js';
|
||||
import type {
|
||||
HookConfig,
|
||||
|
|
@ -33,6 +34,8 @@ import type {
|
|||
PermissionSuggestion,
|
||||
SubagentStartInput,
|
||||
SubagentStopInput,
|
||||
MessagesProvider,
|
||||
FunctionHookContext,
|
||||
StopFailureInput,
|
||||
StopFailureErrorType,
|
||||
} from './types.js';
|
||||
|
|
@ -51,17 +54,38 @@ export class HookEventHandler {
|
|||
private readonly hookPlanner: HookPlanner;
|
||||
private readonly hookRunner: HookRunner;
|
||||
private readonly hookAggregator: HookAggregator;
|
||||
private readonly sessionHooksManager: SessionHooksManager;
|
||||
/** Optional provider for conversation history */
|
||||
private messagesProvider?: MessagesProvider;
|
||||
|
||||
constructor(
|
||||
config: Config,
|
||||
hookPlanner: HookPlanner,
|
||||
hookRunner: HookRunner,
|
||||
hookAggregator: HookAggregator,
|
||||
sessionHooksManager: SessionHooksManager,
|
||||
messagesProvider?: MessagesProvider,
|
||||
) {
|
||||
this.config = config;
|
||||
this.hookPlanner = hookPlanner;
|
||||
this.hookRunner = hookRunner;
|
||||
this.hookAggregator = hookAggregator;
|
||||
this.sessionHooksManager = sessionHooksManager;
|
||||
this.messagesProvider = messagesProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the messages provider for automatic conversation history passing
|
||||
*/
|
||||
setMessagesProvider(provider: MessagesProvider): void {
|
||||
this.messagesProvider = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current messages provider
|
||||
*/
|
||||
getMessagesProvider(): MessagesProvider | undefined {
|
||||
return this.messagesProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -460,10 +484,26 @@ export class HookEventHandler {
|
|||
signal?: AbortSignal,
|
||||
): Promise<AggregatedHookResult> {
|
||||
try {
|
||||
// Create execution plan
|
||||
// Create execution plan from registry hooks
|
||||
const plan = this.hookPlanner.createExecutionPlan(eventName, context);
|
||||
|
||||
if (!plan || plan.hookConfigs.length === 0) {
|
||||
// Get session hooks and merge with registry hooks
|
||||
const sessionId = input.session_id;
|
||||
const targetName = context?.toolName || '';
|
||||
const sessionHooks = sessionId
|
||||
? this.sessionHooksManager.getMatchingHooks(
|
||||
sessionId,
|
||||
eventName,
|
||||
targetName,
|
||||
)
|
||||
: [];
|
||||
|
||||
// Merge hook configs from registry plan and session hooks
|
||||
const registryHookConfigs = plan?.hookConfigs || [];
|
||||
const sessionHookConfigs = sessionHooks.map((entry) => entry.config);
|
||||
const allHookConfigs = [...registryHookConfigs, ...sessionHookConfigs];
|
||||
|
||||
if (allHookConfigs.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
allOutputs: [],
|
||||
|
|
@ -472,10 +512,25 @@ export class HookEventHandler {
|
|||
};
|
||||
}
|
||||
|
||||
// Determine execution strategy: sequential if any hook requires it
|
||||
const sequential =
|
||||
(plan?.sequential ?? false) ||
|
||||
sessionHooks.some((entry) => entry.sequential === true);
|
||||
|
||||
// Build function hook context with messages from provider
|
||||
const messages = this.messagesProvider?.();
|
||||
const functionContext: FunctionHookContext = {
|
||||
messages,
|
||||
toolUseID:
|
||||
'tool_use_id' in input ? (input.tool_use_id as string) : undefined,
|
||||
signal,
|
||||
};
|
||||
|
||||
const totalHooks = allHookConfigs.length;
|
||||
const onHookStart = (config: HookConfig, index: number) => {
|
||||
const hookName = this.getHookName(config);
|
||||
debugLogger.debug(
|
||||
`Hook ${hookName} started for event ${eventName} (${index + 1}/${plan.hookConfigs.length})`,
|
||||
`Hook ${hookName} started for event ${eventName} (${index + 1}/${totalHooks})`,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -486,23 +541,25 @@ export class HookEventHandler {
|
|||
);
|
||||
};
|
||||
|
||||
// Execute hooks according to the plan's strategy
|
||||
const results = plan.sequential
|
||||
// Execute hooks according to the merged strategy
|
||||
const results = sequential
|
||||
? await this.hookRunner.executeHooksSequential(
|
||||
plan.hookConfigs,
|
||||
allHookConfigs,
|
||||
eventName,
|
||||
input,
|
||||
onHookStart,
|
||||
onHookEnd,
|
||||
signal,
|
||||
functionContext,
|
||||
)
|
||||
: await this.hookRunner.executeHooksParallel(
|
||||
plan.hookConfigs,
|
||||
allHookConfigs,
|
||||
eventName,
|
||||
input,
|
||||
onHookStart,
|
||||
onHookEnd,
|
||||
signal,
|
||||
functionContext,
|
||||
);
|
||||
|
||||
// Aggregate results
|
||||
|
|
@ -646,7 +703,9 @@ export class HookEventHandler {
|
|||
/**
|
||||
* Get hook type from execution result for telemetry
|
||||
*/
|
||||
private getHookTypeFromResult(result: HookExecutionResult): 'command' {
|
||||
return result.hookConfig.type as 'command';
|
||||
private getHookTypeFromResult(
|
||||
result: HookExecutionResult,
|
||||
): 'command' | 'http' | 'function' {
|
||||
return result.hookConfig.type;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ describe('HookPlanner', () => {
|
|||
});
|
||||
|
||||
it('should deduplicate hooks with same config', () => {
|
||||
const config = { type: HookType.Command, command: 'echo test' };
|
||||
const config = { type: HookType.Command as const, command: 'echo test' };
|
||||
const entry1: HookRegistryEntry = {
|
||||
config,
|
||||
source: HooksConfigSource.Project,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ describe('HookRegistry', () => {
|
|||
mockConfig = {
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getHooks: vi.fn().mockReturnValue(undefined),
|
||||
getUserHooks: vi.fn().mockReturnValue(undefined),
|
||||
getProjectHooks: vi.fn().mockReturnValue(undefined),
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
|
|
@ -57,7 +57,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -65,24 +65,133 @@ describe('HookRegistry', () => {
|
|||
const allHooks = registry.getAllHooks();
|
||||
expect(allHooks).toHaveLength(1);
|
||||
expect(allHooks[0].eventName).toBe(HookEventName.PreToolUse);
|
||||
expect(allHooks[0].source).toBe(HooksConfigSource.Project);
|
||||
expect(allHooks[0].source).toBe(HooksConfigSource.User);
|
||||
});
|
||||
|
||||
it('should not process project hooks in untrusted folder', async () => {
|
||||
it('should process user hooks even in untrusted folder', async () => {
|
||||
mockConfig.isTrustedFolder = vi.fn().mockReturnValue(false);
|
||||
const hooksConfig = {
|
||||
const userHooksConfig = {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
hooks: [{ type: HookType.Command, command: 'echo test' }],
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo user',
|
||||
name: 'user-hook',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooksConfig);
|
||||
mockConfig.getProjectHooks = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
||||
expect(registry.getAllHooks()).toHaveLength(0);
|
||||
const allHooks = registry.getAllHooks();
|
||||
expect(allHooks).toHaveLength(1);
|
||||
expect(allHooks[0].source).toBe(HooksConfigSource.User);
|
||||
});
|
||||
|
||||
it('should load hooks from getUserHooks regardless of trust', async () => {
|
||||
// In the new design, the CLI filters workspace hooks before passing to core
|
||||
// So core just loads whatever getUserHooks returns
|
||||
const hooksConfig = {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo test',
|
||||
name: 'test-hook',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getProjectHooks = vi.fn().mockReturnValue(undefined);
|
||||
mockConfig.isTrustedFolder = vi.fn().mockReturnValue(false);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
||||
// Hooks should be loaded because CLI already filtered them
|
||||
expect(registry.getAllHooks()).toHaveLength(1);
|
||||
expect(registry.getAllHooks()[0].source).toBe(HooksConfigSource.User);
|
||||
});
|
||||
|
||||
it('should load both user and project hooks in trusted folder', async () => {
|
||||
mockConfig.isTrustedFolder = vi.fn().mockReturnValue(true);
|
||||
const userHooksConfig = {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo user',
|
||||
name: 'user-hook',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const projectHooksConfig = {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo project',
|
||||
name: 'project-hook',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooksConfig);
|
||||
mockConfig.getProjectHooks = vi.fn().mockReturnValue(projectHooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
||||
const allHooks = registry.getAllHooks();
|
||||
expect(allHooks).toHaveLength(2);
|
||||
// User hooks should have priority (lower number) over project hooks
|
||||
expect(allHooks[0].source).toBe(HooksConfigSource.User);
|
||||
expect(allHooks[0].config.name).toBe('user-hook');
|
||||
expect(allHooks[1].source).toBe(HooksConfigSource.Project);
|
||||
expect(allHooks[1].config.name).toBe('project-hook');
|
||||
});
|
||||
|
||||
it('should not load project hooks in untrusted folder', async () => {
|
||||
mockConfig.isTrustedFolder = vi.fn().mockReturnValue(false);
|
||||
const userHooksConfig = {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo user',
|
||||
name: 'user-hook',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooksConfig);
|
||||
// getProjectHooks should return undefined in untrusted folder
|
||||
// (this is handled by Config.getProjectHooks() checking trust)
|
||||
mockConfig.getProjectHooks = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
||||
const allHooks = registry.getAllHooks();
|
||||
expect(allHooks).toHaveLength(1);
|
||||
expect(allHooks[0].source).toBe(HooksConfigSource.User);
|
||||
expect(allHooks[0].config.name).toBe('user-hook');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -108,7 +217,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -141,7 +250,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -153,29 +262,49 @@ describe('HookRegistry', () => {
|
|||
});
|
||||
|
||||
it('should sort hooks by source priority', async () => {
|
||||
// This test requires multiple sources, which would need getUserHooks
|
||||
// For now, we test with extensions which are processed after project hooks
|
||||
const projectHooks = {
|
||||
// Test with user hooks and extension hooks to verify source priority
|
||||
const userHooks = {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo project',
|
||||
name: 'project-hook',
|
||||
command: 'echo user',
|
||||
name: 'user-hook',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(projectHooks);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(userHooks);
|
||||
mockConfig.getExtensions = vi.fn().mockReturnValue([
|
||||
{
|
||||
isActive: true,
|
||||
hooks: {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo extension',
|
||||
name: 'extension-hook',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
||||
const hooks = registry.getHooksForEvent(HookEventName.PreToolUse);
|
||||
expect(hooks).toHaveLength(1);
|
||||
expect(hooks[0].source).toBe(HooksConfigSource.Project);
|
||||
// Should have both user and extension hooks
|
||||
expect(hooks).toHaveLength(2);
|
||||
// User hooks have higher priority (lower number) than extensions
|
||||
expect(hooks[0].source).toBe(HooksConfigSource.User);
|
||||
expect(hooks[1].source).toBe(HooksConfigSource.Extensions);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -194,7 +323,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -223,7 +352,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -258,7 +387,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -296,7 +425,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -312,7 +441,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -320,6 +449,86 @@ describe('HookRegistry', () => {
|
|||
expect(registry.getAllHooks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should discard HTTP hooks without url field', async () => {
|
||||
const hooksConfig = {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
hooks: [{ type: HookType.Http } as HookConfig],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
||||
expect(registry.getAllHooks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should discard function hooks without callback field', async () => {
|
||||
const hooksConfig = {
|
||||
[HookEventName.SessionStart]: [
|
||||
{
|
||||
hooks: [{ type: HookType.Function } as HookConfig],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
||||
expect(registry.getAllHooks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept valid HTTP hooks with url', async () => {
|
||||
const hooksConfig = {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Http,
|
||||
url: 'http://localhost:8080/hook',
|
||||
name: 'http-hook',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
||||
expect(registry.getAllHooks()).toHaveLength(1);
|
||||
expect(registry.getAllHooks()[0].config.type).toBe(HookType.Http);
|
||||
});
|
||||
|
||||
it('should accept valid function hooks with callback', async () => {
|
||||
const callback = vi.fn();
|
||||
const hooksConfig = {
|
||||
[HookEventName.SessionStart]: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Function,
|
||||
callback,
|
||||
name: 'function-hook',
|
||||
errorMessage: 'Error occurred',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
||||
expect(registry.getAllHooks()).toHaveLength(1);
|
||||
expect(registry.getAllHooks()[0].config.type).toBe(HookType.Function);
|
||||
});
|
||||
|
||||
it('should skip invalid event names', async () => {
|
||||
const hooksConfig = {
|
||||
InvalidEventName: [
|
||||
|
|
@ -328,7 +537,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig, mockFeedbackEmitter);
|
||||
await registry.initialize();
|
||||
|
|
@ -356,7 +565,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -388,7 +597,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -413,7 +622,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -438,7 +647,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -548,7 +757,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -572,7 +781,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
@ -595,13 +804,15 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
||||
const hooks = registry.getAllHooks();
|
||||
expect(hooks[0].config.source).toBe(HooksConfigSource.Project);
|
||||
expect((hooks[0].config as { source?: unknown }).source).toBe(
|
||||
HooksConfigSource.User,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -620,7 +831,7 @@ describe('HookRegistry', () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockConfig.getHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
mockConfig.getUserHooks = vi.fn().mockReturnValue(hooksConfig);
|
||||
|
||||
const registry = new HookRegistry(mockConfig);
|
||||
await registry.initialize();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
HOOKS_CONFIG_FIELDS,
|
||||
} from './types.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { TrustedHooksManager } from './trustedHooks.js';
|
||||
|
||||
const debugLogger = createDebugLogger('HOOK_REGISTRY');
|
||||
|
||||
|
|
@ -30,7 +29,7 @@ export interface ExtensionWithHooks {
|
|||
export interface HookRegistryConfig {
|
||||
getProjectRoot(): string;
|
||||
isTrustedFolder(): boolean;
|
||||
getHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
getUserHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
getProjectHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
getExtensions(): ExtensionWithHooks[];
|
||||
}
|
||||
|
|
@ -126,63 +125,35 @@ export class HookRegistry {
|
|||
private getHookName(
|
||||
entry: HookRegistryEntry | { config: HookConfig },
|
||||
): string {
|
||||
return entry.config.name || entry.config.command || 'unknown-command';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for untrusted project hooks and warn the user
|
||||
*/
|
||||
private checkProjectHooksTrust(): void {
|
||||
const projectHooks = this.config.getProjectHooks();
|
||||
if (!projectHooks) return;
|
||||
|
||||
try {
|
||||
const trustedHooksManager = new TrustedHooksManager();
|
||||
const untrusted = trustedHooksManager.getUntrustedHooks(
|
||||
this.config.getProjectRoot(),
|
||||
projectHooks,
|
||||
);
|
||||
|
||||
if (untrusted.length > 0) {
|
||||
const message = `WARNING: The following project-level hooks have been detected in this workspace:
|
||||
${untrusted.map((h: string) => ` - ${h}`).join('\n')}
|
||||
|
||||
These hooks will be executed. If you did not configure these hooks or do not trust this project,
|
||||
please review the project settings (.qwen/settings.json) and remove them.`;
|
||||
this.feedbackEmitter?.emitFeedback('warning', message);
|
||||
|
||||
// Trust them so we don't warn again
|
||||
trustedHooksManager.trustHooks(
|
||||
this.config.getProjectRoot(),
|
||||
projectHooks,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
debugLogger.warn('Failed to check project hooks trust');
|
||||
}
|
||||
const config = entry.config;
|
||||
if (config.name) return config.name;
|
||||
if (config.type === 'command')
|
||||
return (config as { command?: string }).command || 'unknown-command';
|
||||
if (config.type === 'http')
|
||||
return (config as { url?: string }).url || 'unknown-url';
|
||||
if (config.type === 'function')
|
||||
return (config as { id?: string }).id || 'unknown-function';
|
||||
return 'unknown-hook';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process hooks from the config that was already loaded by the CLI
|
||||
*/
|
||||
private processHooksFromConfig(): void {
|
||||
if (this.config.isTrustedFolder()) {
|
||||
this.checkProjectHooksTrust();
|
||||
// Load user hooks (always available, regardless of folder trust)
|
||||
const userHooks = this.config.getUserHooks();
|
||||
if (userHooks) {
|
||||
this.processHooksConfiguration(userHooks, HooksConfigSource.User);
|
||||
}
|
||||
|
||||
// Get hooks from the main config (this comes from the merged settings)
|
||||
const configHooks = this.config.getHooks();
|
||||
if (configHooks) {
|
||||
if (this.config.isTrustedFolder()) {
|
||||
this.processHooksConfiguration(configHooks, HooksConfigSource.Project);
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
'Project hooks disabled because the folder is not trusted.',
|
||||
);
|
||||
}
|
||||
// Load project hooks (only in trusted folders)
|
||||
// The config.getProjectHooks() already checks trust status internally
|
||||
const projectHooks = this.config.getProjectHooks();
|
||||
if (projectHooks) {
|
||||
this.processHooksConfiguration(projectHooks, HooksConfigSource.Project);
|
||||
}
|
||||
|
||||
// Get hooks from extensions
|
||||
// Extension hooks are always loaded
|
||||
const extensions = this.config.getExtensions() || [];
|
||||
for (const extension of extensions) {
|
||||
if (extension.isActive && extension.hooks) {
|
||||
|
|
@ -273,8 +244,10 @@ please review the project settings (.qwen/settings.json) and remove them.`;
|
|||
continue;
|
||||
}
|
||||
|
||||
// Add source to hook config
|
||||
hookConfig.source = source;
|
||||
// Add source to hook config (only for command and http hooks)
|
||||
if (hookConfig.type !== 'function') {
|
||||
(hookConfig as { source?: HooksConfigSource }).source = source;
|
||||
}
|
||||
|
||||
this.entries.push({
|
||||
config: hookConfig,
|
||||
|
|
@ -302,7 +275,10 @@ please review the project settings (.qwen/settings.json) and remove them.`;
|
|||
eventName: HookEventName,
|
||||
source: HooksConfigSource,
|
||||
): boolean {
|
||||
if (!config.type || !['command', 'plugin'].includes(config.type)) {
|
||||
if (
|
||||
!config.type ||
|
||||
!['command', 'http', 'function'].includes(config.type)
|
||||
) {
|
||||
debugLogger.warn(
|
||||
`Invalid hook ${eventName} from ${source} type: ${config.type}`,
|
||||
);
|
||||
|
|
@ -316,6 +292,20 @@ please review the project settings (.qwen/settings.json) and remove them.`;
|
|||
return false;
|
||||
}
|
||||
|
||||
if (config.type === 'http' && !config.url) {
|
||||
debugLogger.warn(
|
||||
`HTTP hook ${eventName} from ${source} missing url field`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.type === 'function' && typeof config.callback !== 'function') {
|
||||
debugLogger.warn(
|
||||
`Function hook ${eventName} from ${source} missing or invalid callback`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -740,4 +740,73 @@ describe('HookRunner', () => {
|
|||
expect(result.output?.decision).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell configuration', () => {
|
||||
it('should use global shell configuration when hookConfig.shell is not specified', async () => {
|
||||
const mockProcess = createMockProcess(0, '{"continue": true}');
|
||||
mockSpawn.mockImplementation(() => mockProcess);
|
||||
|
||||
const hookConfig: HookConfig = {
|
||||
type: HookType.Command,
|
||||
command: 'echo test',
|
||||
source: HooksConfigSource.Project,
|
||||
// No shell specified - should use global config
|
||||
};
|
||||
const input = createMockInput();
|
||||
|
||||
await hookRunner.executeHook(hookConfig, HookEventName.PreToolUse, input);
|
||||
|
||||
// Verify spawn was called with global shell config
|
||||
expect(mockSpawn).toHaveBeenCalled();
|
||||
const spawnArgs = mockSpawn.mock.calls[0];
|
||||
// Global config uses bash or cmd depending on platform
|
||||
expect(spawnArgs[2].shell).toBe(false);
|
||||
});
|
||||
|
||||
it('should use bash shell when hookConfig.shell is bash', async () => {
|
||||
const mockProcess = createMockProcess(0, '{"continue": true}');
|
||||
mockSpawn.mockImplementation(() => mockProcess);
|
||||
|
||||
const hookConfig: HookConfig = {
|
||||
type: HookType.Command,
|
||||
command: 'echo test',
|
||||
source: HooksConfigSource.Project,
|
||||
shell: 'bash',
|
||||
};
|
||||
const input = createMockInput();
|
||||
|
||||
await hookRunner.executeHook(hookConfig, HookEventName.PreToolUse, input);
|
||||
|
||||
// Verify spawn was called with bash configuration
|
||||
expect(mockSpawn).toHaveBeenCalled();
|
||||
const spawnArgs = mockSpawn.mock.calls[0];
|
||||
// Should use bash executable
|
||||
expect(spawnArgs[0]).toMatch(/bash/);
|
||||
expect(spawnArgs[1]).toContain('-c');
|
||||
expect(spawnArgs[2].shell).toBe(false);
|
||||
});
|
||||
|
||||
it('should use powershell when hookConfig.shell is powershell', async () => {
|
||||
const mockProcess = createMockProcess(0, '{"continue": true}');
|
||||
mockSpawn.mockImplementation(() => mockProcess);
|
||||
|
||||
const hookConfig: HookConfig = {
|
||||
type: HookType.Command,
|
||||
command: 'Write-Output test',
|
||||
source: HooksConfigSource.Project,
|
||||
shell: 'powershell',
|
||||
};
|
||||
const input = createMockInput();
|
||||
|
||||
await hookRunner.executeHook(hookConfig, HookEventName.PreToolUse, input);
|
||||
|
||||
// Verify spawn was called with powershell configuration
|
||||
expect(mockSpawn).toHaveBeenCalled();
|
||||
const spawnArgs = mockSpawn.mock.calls[0];
|
||||
// Should use powershell executable
|
||||
expect(spawnArgs[0]).toBe('powershell');
|
||||
expect(spawnArgs[1]).toContain('-Command');
|
||||
expect(spawnArgs[2].shell).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { HookEventName } from './types.js';
|
||||
import { HookEventName, HookType } from './types.js';
|
||||
import type {
|
||||
HookConfig,
|
||||
HookInput,
|
||||
|
|
@ -13,13 +13,19 @@ import type {
|
|||
HookExecutionResult,
|
||||
PreToolUseInput,
|
||||
UserPromptSubmitInput,
|
||||
CommandHookConfig,
|
||||
FunctionHookContext,
|
||||
} from './types.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
escapeShellArg,
|
||||
getShellConfiguration,
|
||||
type ShellType,
|
||||
type ShellConfiguration,
|
||||
} from '../utils/shell-utils.js';
|
||||
import { HttpHookRunner } from './httpHookRunner.js';
|
||||
import { FunctionHookRunner } from './functionHookRunner.js';
|
||||
import { AsyncHookRegistry, generateHookId } from './asyncHookRegistry.js';
|
||||
|
||||
const debugLogger = createDebugLogger('TRUSTED_HOOKS');
|
||||
|
||||
|
|
@ -41,47 +47,116 @@ const EXIT_CODE_SUCCESS = 0;
|
|||
const EXIT_CODE_NON_BLOCKING_ERROR = 1;
|
||||
|
||||
/**
|
||||
* Hook runner that executes command hooks
|
||||
* Hook runner that executes command, HTTP, and function hooks
|
||||
*/
|
||||
export class HookRunner {
|
||||
private readonly httpRunner: HttpHookRunner;
|
||||
private readonly functionRunner: FunctionHookRunner;
|
||||
private readonly asyncRegistry: AsyncHookRegistry;
|
||||
|
||||
constructor(allowedHttpUrls?: string[]) {
|
||||
this.httpRunner = new HttpHookRunner(allowedHttpUrls);
|
||||
this.functionRunner = new FunctionHookRunner();
|
||||
this.asyncRegistry = new AsyncHookRegistry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the async hook registry
|
||||
*/
|
||||
getAsyncRegistry(): AsyncHookRegistry {
|
||||
return this.asyncRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update allowed HTTP URLs
|
||||
*/
|
||||
updateAllowedHttpUrls(allowedUrls: string[]): void {
|
||||
this.httpRunner.updateAllowedUrls(allowedUrls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single hook
|
||||
* @param hookConfig Hook configuration
|
||||
* @param eventName Event name
|
||||
* @param input Hook input
|
||||
* @param signal Optional AbortSignal to cancel hook execution
|
||||
* @param contextOrSignal Optional context (for function hooks) or AbortSignal
|
||||
*/
|
||||
async executeHook(
|
||||
hookConfig: HookConfig,
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
signal?: AbortSignal,
|
||||
contextOrSignal?: FunctionHookContext | AbortSignal,
|
||||
): Promise<HookExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Extract signal from context or use directly
|
||||
const signal =
|
||||
contextOrSignal && 'aborted' in contextOrSignal
|
||||
? contextOrSignal
|
||||
: contextOrSignal?.signal;
|
||||
|
||||
// Check if already aborted before starting
|
||||
if (signal?.aborted) {
|
||||
const hookId = hookConfig.name || hookConfig.command || 'unknown';
|
||||
const hookId = this.getHookId(hookConfig);
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
outcome: 'cancelled',
|
||||
error: new Error(`Hook execution cancelled (aborted): ${hookId}`),
|
||||
duration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.executeCommandHook(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
startTime,
|
||||
signal,
|
||||
);
|
||||
// Check if this is an async command hook
|
||||
if (this.isAsyncHook(hookConfig)) {
|
||||
return this.executeAsyncHook(
|
||||
hookConfig as CommandHookConfig,
|
||||
eventName,
|
||||
input,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
// Route to appropriate runner based on hook type
|
||||
switch (hookConfig.type) {
|
||||
case HookType.Command:
|
||||
return await this.executeCommandHook(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
startTime,
|
||||
signal,
|
||||
);
|
||||
case HookType.Http:
|
||||
return await this.httpRunner.execute(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
signal,
|
||||
);
|
||||
case HookType.Function: {
|
||||
// Function hooks accept context, not just signal
|
||||
const functionContext =
|
||||
contextOrSignal && !('aborted' in contextOrSignal)
|
||||
? contextOrSignal
|
||||
: { signal };
|
||||
return await this.functionRunner.execute(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
functionContext,
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown hook type: ${(hookConfig as HookConfig).type}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const hookId = hookConfig.name || hookConfig.command || 'unknown';
|
||||
const hookId = this.getHookId(hookConfig);
|
||||
const errorMessage = `Hook execution failed for event '${eventName}' (hook: ${hookId}): ${error}`;
|
||||
debugLogger.warn(`Hook execution error (non-fatal): ${errorMessage}`);
|
||||
|
||||
|
|
@ -95,9 +170,219 @@ export class HookRunner {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hook should be executed asynchronously
|
||||
*/
|
||||
private isAsyncHook(hookConfig: HookConfig): boolean {
|
||||
return hookConfig.type === HookType.Command && hookConfig.async === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a unique identifier for a hook
|
||||
*/
|
||||
private getHookId(hookConfig: HookConfig): string {
|
||||
if (hookConfig.name) {
|
||||
return hookConfig.name;
|
||||
}
|
||||
switch (hookConfig.type) {
|
||||
case HookType.Command:
|
||||
return hookConfig.command || 'unknown-command';
|
||||
case HookType.Http:
|
||||
return hookConfig.url || 'unknown-url';
|
||||
case HookType.Function:
|
||||
return hookConfig.id || 'unknown-function';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shell configuration for a hook, respecting hookConfig.shell override
|
||||
*/
|
||||
private getShellConfigForHook(
|
||||
hookConfig: CommandHookConfig,
|
||||
): ShellConfiguration {
|
||||
const globalConfig = getShellConfiguration();
|
||||
|
||||
// If hook specifies a shell, use it
|
||||
if (hookConfig.shell) {
|
||||
const shellType: ShellType =
|
||||
hookConfig.shell === 'powershell' ? 'powershell' : 'bash';
|
||||
|
||||
// Return configuration for the specified shell type
|
||||
if (shellType === 'powershell') {
|
||||
return {
|
||||
shell: 'powershell',
|
||||
executable: 'powershell',
|
||||
argsPrefix: ['-Command'],
|
||||
};
|
||||
}
|
||||
|
||||
// For bash, use global config's executable path or fallback
|
||||
return {
|
||||
shell: 'bash',
|
||||
executable:
|
||||
globalConfig.shell === 'bash' ? globalConfig.executable : 'bash',
|
||||
argsPrefix: ['-c'],
|
||||
};
|
||||
}
|
||||
|
||||
// Use global configuration
|
||||
return globalConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command hook asynchronously (non-blocking)
|
||||
*/
|
||||
private async executeAsyncHook(
|
||||
hookConfig: CommandHookConfig,
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HookExecutionResult> {
|
||||
const hookId = generateHookId();
|
||||
const hookName = hookConfig.name || hookConfig.command || 'async-hook';
|
||||
|
||||
// Check concurrency limit before registering
|
||||
if (!this.asyncRegistry.canAcceptMore()) {
|
||||
debugLogger.warn(
|
||||
`Async hook rejected due to concurrency limit: ${hookName}`,
|
||||
);
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
duration: 0,
|
||||
isAsync: true,
|
||||
error: new Error(
|
||||
'Async hook rejected: too many concurrent async hooks running',
|
||||
),
|
||||
output: { continue: true }, // Non-blocking, continue execution
|
||||
};
|
||||
}
|
||||
|
||||
// Register in async registry
|
||||
const registeredId = this.asyncRegistry.register({
|
||||
hookId,
|
||||
hookName,
|
||||
hookEvent: eventName,
|
||||
sessionId: input.session_id,
|
||||
startTime: Date.now(),
|
||||
timeout: hookConfig.timeout || DEFAULT_HOOK_TIMEOUT,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
// Double-check registration succeeded (race condition protection)
|
||||
if (!registeredId) {
|
||||
debugLogger.warn(
|
||||
`Async hook registration failed due to concurrency limit: ${hookName}`,
|
||||
);
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
duration: 0,
|
||||
isAsync: true,
|
||||
error: new Error(
|
||||
'Async hook rejected: too many concurrent async hooks running',
|
||||
),
|
||||
output: { continue: true },
|
||||
};
|
||||
}
|
||||
|
||||
// Execute in background with proper error handling
|
||||
this.executeCommandHookInBackground(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
hookId,
|
||||
signal,
|
||||
).catch((error) => {
|
||||
// This catch handles any unexpected errors that escape the try-catch in executeCommandHookInBackground
|
||||
debugLogger.error(
|
||||
`Unexpected error in async hook background execution: ${hookId} (${hookName}): ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
// Ensure the hook is marked as failed in the registry
|
||||
try {
|
||||
this.asyncRegistry.fail(
|
||||
hookId,
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(`Unexpected error: ${String(error)}`),
|
||||
);
|
||||
} catch (registryError) {
|
||||
// Registry operation failed, log but don't throw
|
||||
debugLogger.error(
|
||||
`Failed to update async registry for hook ${hookId}: ${registryError}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Return immediately with success
|
||||
debugLogger.debug(`Started async hook: ${hookId} (${hookName})`);
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: true,
|
||||
duration: 0,
|
||||
isAsync: true,
|
||||
output: { continue: true },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command hook in the background
|
||||
*/
|
||||
private async executeCommandHookInBackground(
|
||||
hookConfig: CommandHookConfig,
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
hookId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const hookName = hookConfig.name || hookConfig.command || 'async-hook';
|
||||
|
||||
try {
|
||||
debugLogger.debug(`Executing async hook in background: ${hookId}`);
|
||||
|
||||
const result = await this.executeCommandHook(
|
||||
hookConfig,
|
||||
eventName,
|
||||
input,
|
||||
Date.now(),
|
||||
signal,
|
||||
);
|
||||
|
||||
// Update registry with result
|
||||
if (result.success) {
|
||||
this.asyncRegistry.updateOutput(hookId, result.stdout, result.stderr);
|
||||
this.asyncRegistry.complete(hookId, result.output);
|
||||
debugLogger.debug(
|
||||
`Async hook completed successfully: ${hookId} (${hookName})`,
|
||||
);
|
||||
} else {
|
||||
const error = result.error || new Error('Unknown error');
|
||||
this.asyncRegistry.fail(hookId, error);
|
||||
debugLogger.warn(
|
||||
`Async hook failed: ${hookId} (${hookName}): ${error.message}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorObj =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
this.asyncRegistry.fail(hookId, errorObj);
|
||||
debugLogger.error(
|
||||
`Async hook threw exception: ${hookId} (${hookName}): ${errorObj.message}`,
|
||||
);
|
||||
// Re-throw to be caught by the .catch() in executeAsyncHook
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple hooks in parallel
|
||||
* @param signal Optional AbortSignal to cancel hook execution
|
||||
* @param context Optional function hook context (messages, toolUseID)
|
||||
*/
|
||||
async executeHooksParallel(
|
||||
hookConfigs: HookConfig[],
|
||||
|
|
@ -106,10 +391,14 @@ export class HookRunner {
|
|||
onHookStart?: (config: HookConfig, index: number) => void,
|
||||
onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void,
|
||||
signal?: AbortSignal,
|
||||
context?: FunctionHookContext,
|
||||
): Promise<HookExecutionResult[]> {
|
||||
const promises = hookConfigs.map(async (config, index) => {
|
||||
onHookStart?.(config, index);
|
||||
const result = await this.executeHook(config, eventName, input, signal);
|
||||
const result = await this.executeHook(config, eventName, input, {
|
||||
...context,
|
||||
signal,
|
||||
});
|
||||
onHookEnd?.(config, result);
|
||||
return result;
|
||||
});
|
||||
|
|
@ -119,7 +408,7 @@ export class HookRunner {
|
|||
|
||||
/**
|
||||
* Execute multiple hooks sequentially
|
||||
* @param signal Optional AbortSignal to cancel hook execution
|
||||
* @param context Optional function hook context (messages, toolUseID)
|
||||
*/
|
||||
async executeHooksSequential(
|
||||
hookConfigs: HookConfig[],
|
||||
|
|
@ -128,6 +417,7 @@ export class HookRunner {
|
|||
onHookStart?: (config: HookConfig, index: number) => void,
|
||||
onHookEnd?: (config: HookConfig, result: HookExecutionResult) => void,
|
||||
signal?: AbortSignal,
|
||||
context?: FunctionHookContext,
|
||||
): Promise<HookExecutionResult[]> {
|
||||
const results: HookExecutionResult[] = [];
|
||||
let currentInput = input;
|
||||
|
|
@ -139,12 +429,10 @@ export class HookRunner {
|
|||
}
|
||||
const config = hookConfigs[i];
|
||||
onHookStart?.(config, i);
|
||||
const result = await this.executeHook(
|
||||
config,
|
||||
eventName,
|
||||
currentInput,
|
||||
const result = await this.executeHook(config, eventName, currentInput, {
|
||||
...context,
|
||||
signal,
|
||||
);
|
||||
});
|
||||
onHookEnd?.(config, result);
|
||||
results.push(result);
|
||||
|
||||
|
|
@ -222,7 +510,7 @@ export class HookRunner {
|
|||
* @param signal Optional AbortSignal to cancel hook execution
|
||||
*/
|
||||
private async executeCommandHook(
|
||||
hookConfig: HookConfig,
|
||||
hookConfig: CommandHookConfig,
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
startTime: number,
|
||||
|
|
@ -251,7 +539,8 @@ export class HookRunner {
|
|||
let timedOut = false;
|
||||
let aborted = false;
|
||||
|
||||
const shellConfig = getShellConfiguration();
|
||||
// Use hook-specific shell configuration if specified
|
||||
const shellConfig = this.getShellConfigForHook(hookConfig);
|
||||
const command = this.expandCommand(
|
||||
hookConfig.command,
|
||||
input,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { HookRunner } from './hookRunner.js';
|
|||
import { HookAggregator } from './hookAggregator.js';
|
||||
import { HookPlanner } from './hookPlanner.js';
|
||||
import { HookEventHandler } from './hookEventHandler.js';
|
||||
import { SessionHooksManager } from './sessionHooksManager.js';
|
||||
import {
|
||||
HookType,
|
||||
HooksConfigSource,
|
||||
|
|
@ -59,6 +60,7 @@ describe('HookSystem', () => {
|
|||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getTranscriptPath: vi.fn().mockReturnValue('/test/transcript'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/test/cwd'),
|
||||
getAllowedHttpHookUrls: vi.fn().mockReturnValue([]),
|
||||
} as unknown as Config;
|
||||
|
||||
mockHookRegistry = {
|
||||
|
|
@ -94,6 +96,7 @@ describe('HookSystem', () => {
|
|||
firePermissionRequestEvent: vi.fn(),
|
||||
fireSubagentStartEvent: vi.fn(),
|
||||
fireSubagentStopEvent: vi.fn(),
|
||||
setMessagesProvider: vi.fn(),
|
||||
} as unknown as HookEventHandler;
|
||||
|
||||
vi.mocked(HookRegistry).mockImplementation(() => mockHookRegistry);
|
||||
|
|
@ -116,6 +119,7 @@ describe('HookSystem', () => {
|
|||
mockHookPlanner,
|
||||
mockHookRunner,
|
||||
mockHookAggregator,
|
||||
expect.any(SessionHooksManager),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -169,7 +173,7 @@ describe('HookSystem', () => {
|
|||
const mockHooks = [
|
||||
{
|
||||
config: {
|
||||
type: HookType.Command,
|
||||
type: HookType.Command as const,
|
||||
command: 'echo test',
|
||||
source: HooksConfigSource.Project,
|
||||
},
|
||||
|
|
@ -1662,4 +1666,23 @@ describe('HookSystem', () => {
|
|||
expect(result?.isBlockingDecision()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessagesProvider', () => {
|
||||
it('should set messagesProvider and forward to eventHandler', () => {
|
||||
const provider = vi
|
||||
.fn()
|
||||
.mockReturnValue([{ role: 'user', content: 'test' }]);
|
||||
|
||||
hookSystem.setMessagesProvider(provider);
|
||||
|
||||
expect(mockHookEventHandler.setMessagesProvider).toHaveBeenCalledWith(
|
||||
provider,
|
||||
);
|
||||
expect(hookSystem.getMessagesProvider()).toBe(provider);
|
||||
});
|
||||
|
||||
it('should return undefined when no provider is set', () => {
|
||||
expect(hookSystem.getMessagesProvider()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,8 +24,19 @@ import type {
|
|||
NotificationType,
|
||||
PermissionSuggestion,
|
||||
HookEventName,
|
||||
FunctionHookCallback,
|
||||
CommandHookConfig,
|
||||
HttpHookConfig,
|
||||
PendingAsyncHook,
|
||||
PendingAsyncOutput,
|
||||
MessagesProvider,
|
||||
StopFailureErrorType,
|
||||
} from './types.js';
|
||||
import { SessionHooksManager } from './sessionHooksManager.js';
|
||||
import type { AsyncHookRegistry } from './asyncHookRegistry.js';
|
||||
|
||||
// Re-export MessagesProvider for external use
|
||||
export type { MessagesProvider } from './types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('TRUSTED_HOOKS');
|
||||
|
||||
|
|
@ -39,18 +50,26 @@ export class HookSystem {
|
|||
private readonly hookAggregator: HookAggregator;
|
||||
private readonly hookPlanner: HookPlanner;
|
||||
private readonly hookEventHandler: HookEventHandler;
|
||||
private readonly sessionHooksManager: SessionHooksManager;
|
||||
/** Optional provider for automatically fetching conversation history */
|
||||
private messagesProvider?: MessagesProvider;
|
||||
|
||||
constructor(config: Config) {
|
||||
// Get allowed HTTP URLs from config
|
||||
const allowedHttpUrls = config.getAllowedHttpHookUrls();
|
||||
|
||||
// Initialize components
|
||||
this.hookRegistry = new HookRegistry(config);
|
||||
this.hookRunner = new HookRunner();
|
||||
this.hookRunner = new HookRunner(allowedHttpUrls);
|
||||
this.hookAggregator = new HookAggregator();
|
||||
this.hookPlanner = new HookPlanner(this.hookRegistry);
|
||||
this.sessionHooksManager = new SessionHooksManager();
|
||||
this.hookEventHandler = new HookEventHandler(
|
||||
config,
|
||||
this.hookPlanner,
|
||||
this.hookRunner,
|
||||
this.hookAggregator,
|
||||
this.sessionHooksManager,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +81,22 @@ export class HookSystem {
|
|||
debugLogger.debug('Hook system initialized successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the messages provider for automatic conversation history passing
|
||||
* to function hooks during execution
|
||||
*/
|
||||
setMessagesProvider(provider: MessagesProvider): void {
|
||||
this.messagesProvider = provider;
|
||||
this.hookEventHandler.setMessagesProvider(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current messages provider
|
||||
*/
|
||||
getMessagesProvider(): MessagesProvider | undefined {
|
||||
return this.messagesProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hook event bus for firing events
|
||||
*/
|
||||
|
|
@ -371,4 +406,179 @@ export class HookSystem {
|
|||
? createHookOutput('PermissionRequest', result.finalOutput)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// ==================== Session Hooks API ====================
|
||||
|
||||
/**
|
||||
* Add a function hook for a session
|
||||
* @param sessionId Session ID
|
||||
* @param event Hook event name
|
||||
* @param matcher Matcher pattern (e.g., 'Bash', '*', 'Write|Edit', or regex)
|
||||
* @param callback Function callback to execute
|
||||
* @param errorMessage Error message to display on failure
|
||||
* @param options Additional options
|
||||
* @returns Hook ID for later removal
|
||||
*/
|
||||
addFunctionHook(
|
||||
sessionId: string,
|
||||
event: HookEventName,
|
||||
matcher: string,
|
||||
callback: FunctionHookCallback,
|
||||
errorMessage: string,
|
||||
options?: {
|
||||
timeout?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
statusMessage?: string;
|
||||
skillRoot?: string;
|
||||
},
|
||||
): string {
|
||||
return this.sessionHooksManager.addFunctionHook(
|
||||
sessionId,
|
||||
event,
|
||||
matcher,
|
||||
callback,
|
||||
errorMessage,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a command or HTTP hook for a session
|
||||
* @param sessionId Session ID
|
||||
* @param event Hook event name
|
||||
* @param matcher Matcher pattern
|
||||
* @param hook Hook configuration (command or HTTP)
|
||||
* @param options Additional options
|
||||
* @returns Hook ID
|
||||
*/
|
||||
addSessionHook(
|
||||
sessionId: string,
|
||||
event: HookEventName,
|
||||
matcher: string,
|
||||
hook: CommandHookConfig | HttpHookConfig,
|
||||
options?: { sequential?: boolean },
|
||||
): string {
|
||||
return this.sessionHooksManager.addSessionHook(
|
||||
sessionId,
|
||||
event,
|
||||
matcher,
|
||||
hook,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a function hook by ID
|
||||
* @param sessionId Session ID
|
||||
* @param event Hook event name
|
||||
* @param hookId Hook ID to remove
|
||||
* @returns True if hook was found and removed
|
||||
*/
|
||||
removeFunctionHook(
|
||||
sessionId: string,
|
||||
event: HookEventName,
|
||||
hookId: string,
|
||||
): boolean {
|
||||
return this.sessionHooksManager.removeFunctionHook(
|
||||
sessionId,
|
||||
event,
|
||||
hookId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a hook by ID (searches all events)
|
||||
* @param sessionId Session ID
|
||||
* @param hookId Hook ID to remove
|
||||
* @returns True if hook was found and removed
|
||||
*/
|
||||
removeSessionHook(sessionId: string, hookId: string): boolean {
|
||||
return this.sessionHooksManager.removeHook(sessionId, hookId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session has any hooks registered
|
||||
* @param sessionId Session ID
|
||||
* @returns True if session has hooks
|
||||
*/
|
||||
hasSessionHooks(sessionId: string): boolean {
|
||||
return this.sessionHooksManager.hasSessionHooks(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all hooks for a session
|
||||
* @param sessionId Session ID
|
||||
*/
|
||||
clearSessionHooks(sessionId: string): void {
|
||||
this.sessionHooksManager.clearSessionHooks(sessionId);
|
||||
// Also clear async hooks for this session
|
||||
this.getAsyncRegistry().clearSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session hooks manager
|
||||
*/
|
||||
getSessionHooksManager(): SessionHooksManager {
|
||||
return this.sessionHooksManager;
|
||||
}
|
||||
|
||||
// ==================== Async Hooks API ====================
|
||||
|
||||
/**
|
||||
* Get the async hook registry
|
||||
*/
|
||||
getAsyncRegistry(): AsyncHookRegistry {
|
||||
return this.hookRunner.getAsyncRegistry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending async hooks
|
||||
*/
|
||||
getPendingAsyncHooks(): PendingAsyncHook[] {
|
||||
return this.getAsyncRegistry().getPendingHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending async hooks for a specific session
|
||||
*/
|
||||
getPendingAsyncHooksForSession(sessionId: string): PendingAsyncHook[] {
|
||||
return this.getAsyncRegistry().getPendingHooksForSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and clear pending async output for delivery to the next turn
|
||||
*/
|
||||
getPendingAsyncOutput(): PendingAsyncOutput {
|
||||
return this.getAsyncRegistry().getPendingOutput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any pending async outputs
|
||||
*/
|
||||
hasPendingAsyncOutput(): boolean {
|
||||
return this.getAsyncRegistry().hasPendingOutput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any running async hooks
|
||||
*/
|
||||
hasRunningAsyncHooks(): boolean {
|
||||
return this.getAsyncRegistry().hasRunningHooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for timed out async hooks and mark them
|
||||
*/
|
||||
checkAsyncHookTimeouts(): void {
|
||||
this.getAsyncRegistry().checkTimeouts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update allowed HTTP hook URLs
|
||||
*/
|
||||
updateAllowedHttpUrls(allowedUrls: string[]): void {
|
||||
this.hookRunner.updateAllowedHttpUrls(allowedUrls);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
292
packages/core/src/hooks/httpHookRunner.test.ts
Normal file
292
packages/core/src/hooks/httpHookRunner.test.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HookEventName, HookType } from './types.js';
|
||||
import type { HttpHookConfig, HookInput } from './types.js';
|
||||
import { HttpHookRunner } from './httpHookRunner.js';
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
// Mock dns.lookup to avoid real DNS lookups in tests
|
||||
vi.mock('dns', () => ({
|
||||
lookup: (
|
||||
_hostname: string,
|
||||
_options: object,
|
||||
callback: (
|
||||
err: null,
|
||||
addresses: Array<{ address: string; family: number }>,
|
||||
) => void,
|
||||
) => {
|
||||
// Return a mock public IP address
|
||||
callback(null, [{ address: '8.8.8.8', family: 4 }]);
|
||||
},
|
||||
}));
|
||||
|
||||
describe('HttpHookRunner', () => {
|
||||
let httpRunner: HttpHookRunner;
|
||||
const originalEnv = process.env;
|
||||
// Use escaped dots in URL patterns to satisfy CodeQL security scanning
|
||||
// The UrlValidator.compilePattern method also escapes dots, but we use
|
||||
// pre-escaped patterns here to make the security intent explicit
|
||||
const ALLOWED_URL_PATTERN = 'https://api\\.example\\.com/*';
|
||||
|
||||
beforeEach(() => {
|
||||
httpRunner = new HttpHookRunner([ALLOWED_URL_PATTERN]);
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
const createMockInput = (overrides: Partial<HookInput> = {}): HookInput => ({
|
||||
session_id: 'test-session',
|
||||
transcript_path: '/test/transcript',
|
||||
cwd: '/test',
|
||||
hook_event_name: 'PreToolUse',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockConfig = (
|
||||
overrides: Partial<HttpHookConfig> = {},
|
||||
): HttpHookConfig => ({
|
||||
type: HookType.Http,
|
||||
url: 'https://api.example.com/hook',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should fail for URL not in whitelist', async () => {
|
||||
const config = createMockConfig({
|
||||
url: 'https://other.com/hook',
|
||||
});
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await httpRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain('URL validation failed');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail for blocked URL (SSRF - link-local metadata)', async () => {
|
||||
const runner = new HttpHookRunner([]); // Allow all patterns
|
||||
const config = createMockConfig({
|
||||
url: 'http://169.254.169.254/latest/meta-data',
|
||||
});
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await runner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain('blocked');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ALLOW localhost for local dev hooks', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: async () => ({ continue: true }),
|
||||
});
|
||||
|
||||
const runner = new HttpHookRunner([]); // Allow all patterns
|
||||
const config = createMockConfig({
|
||||
url: 'http://localhost:8080/hook',
|
||||
});
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await runner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should interpolate environment variables in headers', async () => {
|
||||
process.env['MY_TOKEN'] = 'secret-token';
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: async () => ({ continue: true }),
|
||||
});
|
||||
|
||||
const config = createMockConfig({
|
||||
headers: { Authorization: 'Bearer $MY_TOKEN' },
|
||||
allowedEnvVars: ['MY_TOKEN'],
|
||||
});
|
||||
const input = createMockInput();
|
||||
|
||||
await httpRunner.execute(config, HookEventName.PreToolUse, input);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer secret-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle HTTP error response as non-blocking error', async () => {
|
||||
// Per Claude Code spec: Non-2xx status is a non-blocking error
|
||||
// Execution continues with success: true
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await httpRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
// Non-2xx is a non-blocking error, so success should be true
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output?.continue).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle timeout as non-blocking error', async () => {
|
||||
// Per Claude Code spec: Timeout is a non-blocking error
|
||||
// Execution continues with success: true
|
||||
mockFetch.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
const error = new Error('Aborted');
|
||||
error.name = 'AbortError';
|
||||
setTimeout(() => reject(error), 10);
|
||||
}),
|
||||
);
|
||||
|
||||
const config = createMockConfig({ timeout: 1 });
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await httpRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
// Timeout is a non-blocking error, so success should be true
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output?.continue).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip once hook on second execution', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: async () => ({ continue: true }),
|
||||
});
|
||||
|
||||
const config = createMockConfig({ once: true });
|
||||
const input = createMockInput();
|
||||
|
||||
// First execution
|
||||
await httpRunner.execute(config, HookEventName.PreToolUse, input);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second execution - should skip
|
||||
const result = await httpRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1); // Still 1
|
||||
});
|
||||
|
||||
it('should parse JSON response with hook output', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: async () => ({
|
||||
decision: 'deny',
|
||||
reason: 'Blocked by policy',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await httpRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output?.decision).toBe('deny');
|
||||
expect(result.output?.reason).toBe('Blocked by policy');
|
||||
});
|
||||
|
||||
it('should handle aborted signal', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const config = createMockConfig();
|
||||
const input = createMockInput();
|
||||
|
||||
const result = await httpRunner.execute(
|
||||
config,
|
||||
HookEventName.PreToolUse,
|
||||
input,
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.message).toContain('cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetOnceHooks', () => {
|
||||
it('should allow once hooks to execute again after reset', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: async () => ({ continue: true }),
|
||||
});
|
||||
|
||||
const config = createMockConfig({ once: true });
|
||||
const input = createMockInput();
|
||||
|
||||
await httpRunner.execute(config, HookEventName.PreToolUse, input);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
httpRunner.resetOnceHooks();
|
||||
|
||||
await httpRunner.execute(config, HookEventName.PreToolUse, input);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
426
packages/core/src/hooks/httpHookRunner.ts
Normal file
426
packages/core/src/hooks/httpHookRunner.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { interpolateHeaders, interpolateUrl } from './envInterpolator.js';
|
||||
import { UrlValidator } from './urlValidator.js';
|
||||
import { createCombinedAbortSignal } from './combinedAbortSignal.js';
|
||||
import { isBlockedAddress } from './ssrfGuard.js';
|
||||
import { lookup as dnsLookup } from 'dns';
|
||||
import type {
|
||||
HttpHookConfig,
|
||||
HookInput,
|
||||
HookOutput,
|
||||
HookExecutionResult,
|
||||
HookEventName,
|
||||
} from './types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('HTTP_HOOK_RUNNER');
|
||||
|
||||
/**
|
||||
* Default timeout for HTTP hook execution
|
||||
*/
|
||||
const DEFAULT_HTTP_TIMEOUT = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Maximum output length (10,000 characters as per Qwen Code spec)
|
||||
*/
|
||||
const MAX_OUTPUT_LENGTH = 10000;
|
||||
|
||||
/**
|
||||
* Callback for displaying status messages during hook execution
|
||||
*/
|
||||
export type StatusMessageCallback = (message: string) => void;
|
||||
|
||||
/**
|
||||
* Resolve a hostname and validate that all resolved IPs are not in blocked
|
||||
* ranges. This is the core of DNS-level SSRF protection, aligned with
|
||||
*
|
||||
* NOTE: Node.js native `fetch` does not support a custom `lookup` option
|
||||
* (unlike axios). We validate resolved IPs immediately before the fetch
|
||||
* call to minimize the rebinding window.
|
||||
*/
|
||||
async function validateResolvedHost(
|
||||
hostname: string,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
// If hostname is already an IP literal, validate directly.
|
||||
if (isBlockedAddress(hostname)) {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: `HTTP hook blocked: ${hostname} is in a private/link-local range`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For hostnames, resolve DNS and validate all returned IPs.
|
||||
dnsLookup(hostname, { all: true }, (err, addresses) => {
|
||||
if (err) {
|
||||
// DNS resolution failure — let the fetch call handle it.
|
||||
resolve({ ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const addr of addresses) {
|
||||
if (isBlockedAddress(addr.address)) {
|
||||
resolve({
|
||||
ok: false,
|
||||
error: `HTTP hook blocked: ${hostname} resolves to ${addr.address} (private/link-local). Loopback (127.0.0.1, ::1) is allowed.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
resolve({ ok: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP Hook Runner - executes HTTP hooks by sending POST requests
|
||||
*/
|
||||
export class HttpHookRunner {
|
||||
private urlValidator: UrlValidator;
|
||||
private readonly executedOnceHooks: Set<string> = new Set();
|
||||
private statusMessageCallback?: StatusMessageCallback;
|
||||
|
||||
constructor(allowedUrls?: string[]) {
|
||||
this.urlValidator = new UrlValidator(allowedUrls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for displaying status messages
|
||||
*/
|
||||
setStatusMessageCallback(callback: StatusMessageCallback): void {
|
||||
this.statusMessageCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an HTTP hook
|
||||
* @param hookConfig HTTP hook configuration
|
||||
* @param eventName Event name
|
||||
* @param input Hook input
|
||||
* @param signal Optional AbortSignal to cancel hook execution
|
||||
*/
|
||||
async execute(
|
||||
hookConfig: HttpHookConfig,
|
||||
eventName: HookEventName,
|
||||
input: HookInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HookExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
const hookId = hookConfig.name || hookConfig.url;
|
||||
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
error: new Error(`HTTP hook execution cancelled (aborted): ${hookId}`),
|
||||
duration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Check once flag
|
||||
if (hookConfig.once) {
|
||||
const onceKey = `${hookConfig.url}:${eventName}`;
|
||||
if (this.executedOnceHooks.has(onceKey)) {
|
||||
debugLogger.debug(
|
||||
`Skipping once hook ${hookId} - already executed for ${eventName}`,
|
||||
);
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: true,
|
||||
duration: 0,
|
||||
output: { continue: true },
|
||||
};
|
||||
}
|
||||
this.executedOnceHooks.add(onceKey);
|
||||
}
|
||||
|
||||
// Display status message if configured
|
||||
if (hookConfig.statusMessage && this.statusMessageCallback) {
|
||||
this.statusMessageCallback(hookConfig.statusMessage);
|
||||
}
|
||||
|
||||
try {
|
||||
// Interpolate URL with allowed env vars
|
||||
const url = interpolateUrl(
|
||||
hookConfig.url,
|
||||
hookConfig.allowedEnvVars || [],
|
||||
);
|
||||
|
||||
// Validate URL format and whitelist (URL-level check)
|
||||
const validation = this.urlValidator.validate(url);
|
||||
if (!validation.allowed) {
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
error: new Error(`URL validation failed: ${validation.reason}`),
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// DNS-level SSRF protection: validate resolved IPs
|
||||
// It checks that the hostname resolves to non-private IPs.
|
||||
const parsed = new URL(url);
|
||||
const hostValidation = await validateResolvedHost(parsed.hostname);
|
||||
if (!hostValidation.ok) {
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
error: new Error(hostValidation.error),
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Interpolate headers with allowed env vars
|
||||
const headers = hookConfig.headers
|
||||
? interpolateHeaders(
|
||||
hookConfig.headers,
|
||||
hookConfig.allowedEnvVars || [],
|
||||
)
|
||||
: {};
|
||||
|
||||
// Prepare request body
|
||||
const body = JSON.stringify({
|
||||
...input,
|
||||
hook_event_name: eventName,
|
||||
});
|
||||
|
||||
// Set up combined abort signal (external signal + timeout)
|
||||
const timeout = hookConfig.timeout
|
||||
? hookConfig.timeout * 1000
|
||||
: DEFAULT_HTTP_TIMEOUT;
|
||||
const { signal: combinedSignal, cleanup } = createCombinedAbortSignal(
|
||||
signal,
|
||||
{ timeoutMs: timeout },
|
||||
);
|
||||
|
||||
try {
|
||||
debugLogger.debug(`Executing HTTP hook: ${hookId} -> ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body,
|
||||
signal: combinedSignal,
|
||||
});
|
||||
|
||||
cleanup();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Per Qwen Code spec: Non-2xx status is a non-blocking error
|
||||
// Execution continues, but we log a warning
|
||||
if (!response.ok) {
|
||||
debugLogger.warn(
|
||||
`HTTP hook ${hookId} returned non-2xx status ${response.status} (non-blocking)`,
|
||||
);
|
||||
// Return success: true with continue: true for non-blocking error
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: true,
|
||||
output: { continue: true },
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse response
|
||||
const output = await this.parseResponse(response, eventName);
|
||||
|
||||
debugLogger.debug(
|
||||
`HTTP hook ${hookId} completed successfully in ${duration}ms`,
|
||||
);
|
||||
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: true,
|
||||
output,
|
||||
duration,
|
||||
};
|
||||
} catch (fetchError) {
|
||||
cleanup();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (
|
||||
fetchError instanceof Error &&
|
||||
(fetchError.name === 'AbortError' || combinedSignal.aborted)
|
||||
) {
|
||||
// Timeout or abort is a non-blocking error per Qwen Code spec
|
||||
debugLogger.warn(
|
||||
`HTTP hook ${hookId} timed out or was aborted after ${timeout}ms (non-blocking)`,
|
||||
);
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: true,
|
||||
output: { continue: true },
|
||||
duration,
|
||||
};
|
||||
}
|
||||
|
||||
// Connection failure is a non-blocking error per Qwen Code spec
|
||||
debugLogger.warn(
|
||||
`HTTP hook ${hookId} connection failed (non-blocking): ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`,
|
||||
);
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: true,
|
||||
output: { continue: true },
|
||||
duration,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
debugLogger.warn(`HTTP hook ${hookId} failed: ${errorMessage}`);
|
||||
|
||||
return {
|
||||
hookConfig,
|
||||
eventName,
|
||||
success: false,
|
||||
error: error instanceof Error ? error : new Error(errorMessage),
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTTP response into HookOutput
|
||||
*/
|
||||
private async parseResponse(
|
||||
response: Response,
|
||||
eventName: HookEventName,
|
||||
): Promise<HookOutput> {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
// Try to parse as JSON
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
const json = await response.json();
|
||||
return this.normalizeOutput(json, eventName);
|
||||
} catch {
|
||||
debugLogger.warn('Failed to parse JSON response, using empty output');
|
||||
return { continue: true };
|
||||
}
|
||||
}
|
||||
|
||||
// For plain text responses, add as context (truncated if needed)
|
||||
const text = await response.text();
|
||||
if (text.trim()) {
|
||||
return {
|
||||
continue: true,
|
||||
systemMessage: this.truncateOutput(text.trim()),
|
||||
};
|
||||
}
|
||||
|
||||
// For empty responses, return success with continue
|
||||
return { continue: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate output to MAX_OUTPUT_LENGTH characters
|
||||
* Per Qwen Code spec: output is capped at 10,000 characters
|
||||
*/
|
||||
private truncateOutput(output: string): string {
|
||||
if (output.length <= MAX_OUTPUT_LENGTH) {
|
||||
return output;
|
||||
}
|
||||
const truncated = output.substring(0, MAX_OUTPUT_LENGTH);
|
||||
debugLogger.debug(
|
||||
`Output truncated from ${output.length} to ${MAX_OUTPUT_LENGTH} characters`,
|
||||
);
|
||||
return `${truncated}\n... [truncated, ${output.length - MAX_OUTPUT_LENGTH} more characters]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize response JSON into HookOutput format
|
||||
*/
|
||||
private normalizeOutput(
|
||||
json: Record<string, unknown>,
|
||||
eventName: HookEventName,
|
||||
): HookOutput {
|
||||
const output: HookOutput = {};
|
||||
|
||||
// Map standard fields
|
||||
if ('continue' in json && typeof json['continue'] === 'boolean') {
|
||||
output.continue = json['continue'];
|
||||
}
|
||||
if ('stopReason' in json && typeof json['stopReason'] === 'string') {
|
||||
output.stopReason = this.truncateOutput(json['stopReason']);
|
||||
}
|
||||
if (
|
||||
'suppressOutput' in json &&
|
||||
typeof json['suppressOutput'] === 'boolean'
|
||||
) {
|
||||
output.suppressOutput = json['suppressOutput'];
|
||||
}
|
||||
if ('systemMessage' in json && typeof json['systemMessage'] === 'string') {
|
||||
// Apply output length limit per Qwen Code spec
|
||||
output.systemMessage = this.truncateOutput(json['systemMessage']);
|
||||
}
|
||||
if ('decision' in json && typeof json['decision'] === 'string') {
|
||||
output.decision = json['decision'] as HookOutput['decision'];
|
||||
}
|
||||
if ('reason' in json && typeof json['reason'] === 'string') {
|
||||
output.reason = this.truncateOutput(json['reason']);
|
||||
}
|
||||
|
||||
// Handle hookSpecificOutput
|
||||
if (
|
||||
'hookSpecificOutput' in json &&
|
||||
typeof json['hookSpecificOutput'] === 'object' &&
|
||||
json['hookSpecificOutput'] !== null
|
||||
) {
|
||||
const hookOutput = json['hookSpecificOutput'] as Record<string, unknown>;
|
||||
// Truncate additionalContext if present
|
||||
if (
|
||||
'additionalContext' in hookOutput &&
|
||||
typeof hookOutput['additionalContext'] === 'string'
|
||||
) {
|
||||
hookOutput['additionalContext'] = this.truncateOutput(
|
||||
hookOutput['additionalContext'],
|
||||
);
|
||||
}
|
||||
output.hookSpecificOutput = hookOutput;
|
||||
// Ensure hookEventName is set
|
||||
if (!('hookEventName' in output.hookSpecificOutput)) {
|
||||
output.hookSpecificOutput['hookEventName'] = eventName;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset once hooks tracking (useful for testing)
|
||||
*/
|
||||
resetOnceHooks(): void {
|
||||
this.executedOnceHooks.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update allowed URLs
|
||||
*/
|
||||
updateAllowedUrls(allowedUrls: string[]): void {
|
||||
// Create new validator with updated patterns
|
||||
this.urlValidator = new UrlValidator(allowedUrls);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,29 @@ export { HookAggregator } from './hookAggregator.js';
|
|||
export { HookPlanner } from './hookPlanner.js';
|
||||
export { HookEventHandler } from './hookEventHandler.js';
|
||||
|
||||
// Export new hook runners
|
||||
export { HttpHookRunner } from './httpHookRunner.js';
|
||||
export { FunctionHookRunner } from './functionHookRunner.js';
|
||||
|
||||
// Export session and async hook management
|
||||
export { SessionHooksManager } from './sessionHooksManager.js';
|
||||
export type { SessionHookEntry } from './sessionHooksManager.js';
|
||||
export { AsyncHookRegistry, generateHookId } from './asyncHookRegistry.js';
|
||||
export {
|
||||
registerSkillHooks,
|
||||
unregisterSkillHooks,
|
||||
} from './registerSkillHooks.js';
|
||||
|
||||
// Export utilities
|
||||
export {
|
||||
interpolateEnvVars,
|
||||
interpolateHeaders,
|
||||
interpolateUrl,
|
||||
hasEnvVarReferences,
|
||||
extractEnvVarNames,
|
||||
} from './envInterpolator.js';
|
||||
export { UrlValidator, createUrlValidator } from './urlValidator.js';
|
||||
|
||||
// Export interfaces and enums
|
||||
export type { HookRegistryEntry } from './hookRegistry.js';
|
||||
export { HooksConfigSource as ConfigSource } from './types.js';
|
||||
|
|
|
|||
229
packages/core/src/hooks/registerSkillHooks.test.ts
Normal file
229
packages/core/src/hooks/registerSkillHooks.test.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { registerSkillHooks } from './registerSkillHooks.js';
|
||||
import { SessionHooksManager } from './sessionHooksManager.js';
|
||||
import { HookEventName, HookType } from './types.js';
|
||||
import type { SkillConfig } from '../skills/types.js';
|
||||
|
||||
describe('registerSkillHooks', () => {
|
||||
let sessionHooksManager: SessionHooksManager;
|
||||
const sessionId = 'test-session';
|
||||
const skillRoot = '/path/to/skill';
|
||||
|
||||
beforeEach(() => {
|
||||
sessionHooksManager = new SessionHooksManager();
|
||||
});
|
||||
|
||||
it('should return 0 when skill has no hooks', () => {
|
||||
const skill: SkillConfig = {
|
||||
name: 'test-skill',
|
||||
description: 'Test skill',
|
||||
level: 'user',
|
||||
filePath: '/path/to/skill/SKILL.md',
|
||||
body: 'Test body',
|
||||
};
|
||||
|
||||
const count = registerSkillHooks(sessionHooksManager, sessionId, skill);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should register a single command hook', () => {
|
||||
const skill: SkillConfig = {
|
||||
name: 'test-skill',
|
||||
description: 'Test skill',
|
||||
level: 'user',
|
||||
filePath: '/path/to/skill/SKILL.md',
|
||||
skillRoot,
|
||||
body: 'Test body',
|
||||
hooks: {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo "checking command"',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const count = registerSkillHooks(sessionHooksManager, sessionId, skill);
|
||||
expect(count).toBe(1);
|
||||
expect(sessionHooksManager.hasSessionHooks(sessionId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should register multiple hooks for different events', () => {
|
||||
const skill: SkillConfig = {
|
||||
name: 'test-skill',
|
||||
description: 'Test skill',
|
||||
level: 'user',
|
||||
filePath: '/path/to/skill/SKILL.md',
|
||||
skillRoot,
|
||||
body: 'Test body',
|
||||
hooks: {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo "pre-tool-use"',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[HookEventName.PostToolUse]: [
|
||||
{
|
||||
matcher: 'Write',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo "post-tool-use"',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const count = registerSkillHooks(sessionHooksManager, sessionId, skill);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it('should register HTTP hooks', () => {
|
||||
const skill: SkillConfig = {
|
||||
name: 'test-skill',
|
||||
description: 'Test skill',
|
||||
level: 'user',
|
||||
filePath: '/path/to/skill/SKILL.md',
|
||||
skillRoot,
|
||||
body: 'Test body',
|
||||
hooks: {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Http,
|
||||
url: 'https://example.com/hook',
|
||||
headers: {
|
||||
Authorization: 'Bearer token',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const count = registerSkillHooks(sessionHooksManager, sessionId, skill);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('should register hooks with matcher pattern', () => {
|
||||
const skill: SkillConfig = {
|
||||
name: 'test-skill',
|
||||
description: 'Test skill',
|
||||
level: 'user',
|
||||
filePath: '/path/to/skill/SKILL.md',
|
||||
skillRoot,
|
||||
body: 'Test body',
|
||||
hooks: {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
matcher: '^(Write|Edit)$',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo "file operation"',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const count = registerSkillHooks(sessionHooksManager, sessionId, skill);
|
||||
expect(count).toBe(1);
|
||||
|
||||
const hooks = sessionHooksManager.getHooksForEvent(
|
||||
sessionId,
|
||||
HookEventName.PreToolUse,
|
||||
);
|
||||
expect(hooks).toHaveLength(1);
|
||||
expect(hooks[0].matcher).toBe('^(Write|Edit)$');
|
||||
});
|
||||
|
||||
it('should register multiple hooks for same event and matcher', () => {
|
||||
const skill: SkillConfig = {
|
||||
name: 'test-skill',
|
||||
description: 'Test skill',
|
||||
level: 'user',
|
||||
filePath: '/path/to/skill/SKILL.md',
|
||||
skillRoot,
|
||||
body: 'Test body',
|
||||
hooks: {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo "first check"',
|
||||
},
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo "second check"',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const count = registerSkillHooks(sessionHooksManager, sessionId, skill);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it('should register hooks with skillRoot for environment variable', () => {
|
||||
const skill: SkillConfig = {
|
||||
name: 'test-skill',
|
||||
description: 'Test skill',
|
||||
level: 'user',
|
||||
filePath: '/path/to/skill/SKILL.md',
|
||||
skillRoot,
|
||||
body: 'Test body',
|
||||
hooks: {
|
||||
[HookEventName.PreToolUse]: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo $QWEN_SKILL_ROOT',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const count = registerSkillHooks(sessionHooksManager, sessionId, skill);
|
||||
expect(count).toBe(1);
|
||||
|
||||
const hooks = sessionHooksManager.getHooksForEvent(
|
||||
sessionId,
|
||||
HookEventName.PreToolUse,
|
||||
);
|
||||
expect(hooks).toHaveLength(1);
|
||||
expect(hooks[0].skillRoot).toBe(skillRoot);
|
||||
});
|
||||
});
|
||||
152
packages/core/src/hooks/registerSkillHooks.ts
Normal file
152
packages/core/src/hooks/registerSkillHooks.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Skill Hooks Registration
|
||||
*
|
||||
* Registers hooks from a skill's frontmatter as session-scoped hooks.
|
||||
* When a skill is invoked, its hooks are registered for the duration
|
||||
* of the session.
|
||||
*/
|
||||
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import type { SessionHooksManager } from './sessionHooksManager.js';
|
||||
import type { SkillHooksSettings, SkillConfig } from '../skills/types.js';
|
||||
import {
|
||||
HookType,
|
||||
type HookEventName,
|
||||
type CommandHookConfig,
|
||||
type HttpHookConfig,
|
||||
} from './types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SKILL_HOOKS');
|
||||
|
||||
/**
|
||||
* Registers hooks from a skill's configuration as session hooks.
|
||||
*
|
||||
* Hooks are registered as session-scoped hooks that persist for the duration
|
||||
* of the session. If a hook has `once: true` in its configuration, it will be
|
||||
* automatically removed after its first successful execution.
|
||||
*
|
||||
* @param sessionHooksManager - The session hooks manager instance
|
||||
* @param sessionId - The current session ID
|
||||
* @param skill - The skill configuration containing hooks
|
||||
* @returns Number of hooks registered
|
||||
*/
|
||||
export function registerSkillHooks(
|
||||
sessionHooksManager: SessionHooksManager,
|
||||
sessionId: string,
|
||||
skill: SkillConfig,
|
||||
): number {
|
||||
if (!skill.hooks) {
|
||||
debugLogger.debug(`Skill '${skill.name}' has no hooks to register`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const hooksSettings: SkillHooksSettings = skill.hooks;
|
||||
let registeredCount = 0;
|
||||
|
||||
for (const eventName of Object.keys(hooksSettings) as HookEventName[]) {
|
||||
const matchers = hooksSettings[eventName];
|
||||
if (!matchers) continue;
|
||||
|
||||
for (const matcher of matchers) {
|
||||
const matcherPattern = matcher.matcher || '';
|
||||
|
||||
for (const hook of matcher.hooks) {
|
||||
// Only register command and HTTP hooks (skip function hooks)
|
||||
if (hook.type === HookType.Function) {
|
||||
debugLogger.debug(
|
||||
'Skipping function hook from skill (not supported in frontmatter)',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Register the hook with skillRoot for environment variable
|
||||
const hookConfig = prepareHookConfig(
|
||||
hook as CommandHookConfig | HttpHookConfig,
|
||||
skill.skillRoot,
|
||||
);
|
||||
|
||||
sessionHooksManager.addSessionHook(
|
||||
sessionId,
|
||||
eventName,
|
||||
matcherPattern,
|
||||
hookConfig,
|
||||
{ skillRoot: skill.skillRoot },
|
||||
);
|
||||
|
||||
registeredCount++;
|
||||
debugLogger.debug(
|
||||
`Registered hook for ${eventName} with matcher '${matcherPattern}' from skill '${skill.name}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (registeredCount > 0) {
|
||||
debugLogger.info(
|
||||
`Registered ${registeredCount} hooks from skill '${skill.name}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return registeredCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares hook config with skillRoot environment variable.
|
||||
*
|
||||
* @param hook - The hook configuration
|
||||
* @param skillRoot - The skill root directory
|
||||
* @returns Prepared hook configuration
|
||||
*/
|
||||
function prepareHookConfig(
|
||||
hook: CommandHookConfig | HttpHookConfig,
|
||||
skillRoot?: string,
|
||||
): CommandHookConfig | HttpHookConfig {
|
||||
if (hook.type === 'command' && skillRoot) {
|
||||
// Add QWEN_SKILL_ROOT to environment variables
|
||||
return {
|
||||
...hook,
|
||||
env: {
|
||||
...hook.env,
|
||||
QWEN_SKILL_ROOT: skillRoot,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return hook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters all hooks from a skill.
|
||||
*
|
||||
* Note: This is typically not needed as session hooks are cleared
|
||||
* when the session ends. However, it can be useful for cleanup
|
||||
* in certain scenarios.
|
||||
*
|
||||
* @param sessionHooksManager - The session hooks manager instance
|
||||
* @param sessionId - The current session ID
|
||||
* @param skill - The skill configuration
|
||||
* @returns Number of hooks unregistered
|
||||
*/
|
||||
export function unregisterSkillHooks(
|
||||
sessionHooksManager: SessionHooksManager,
|
||||
sessionId: string,
|
||||
skill: SkillConfig,
|
||||
): number {
|
||||
if (!skill.hooks) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Note: Current implementation doesn't track hook IDs per skill
|
||||
// Session hooks are cleared when session ends
|
||||
debugLogger.debug(
|
||||
`Skill hooks for '${skill.name}' will be cleared with session`,
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
694
packages/core/src/hooks/sessionHooksManager.test.ts
Normal file
694
packages/core/src/hooks/sessionHooksManager.test.ts
Normal file
|
|
@ -0,0 +1,694 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SessionHooksManager } from './sessionHooksManager.js';
|
||||
import { HookEventName, HookType } from './types.js';
|
||||
import type { CommandHookConfig, HttpHookConfig } from './types.js';
|
||||
|
||||
describe('SessionHooksManager', () => {
|
||||
let manager: SessionHooksManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new SessionHooksManager();
|
||||
});
|
||||
|
||||
describe('addFunctionHook', () => {
|
||||
it('should add a function hook and return hook ID', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
const hookId = manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error message',
|
||||
);
|
||||
|
||||
expect(hookId).toBeDefined();
|
||||
expect(manager.hasSessionHooks('session-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should use provided hook ID', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
const returnedHookId = manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error message',
|
||||
{ id: 'custom-hook-id' },
|
||||
);
|
||||
|
||||
expect(returnedHookId).toBe('custom-hook-id');
|
||||
});
|
||||
|
||||
it('should add hook with options', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error message',
|
||||
{
|
||||
timeout: 30000,
|
||||
name: 'My Hook',
|
||||
description: 'Test hook',
|
||||
},
|
||||
);
|
||||
|
||||
const hooks = manager.getHooksForEvent(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
);
|
||||
expect(hooks.length).toBe(1);
|
||||
expect(hooks[0].config.name).toBe('My Hook');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addSessionHook', () => {
|
||||
it('should add a command hook', () => {
|
||||
const commandHook: CommandHookConfig = {
|
||||
type: HookType.Command,
|
||||
command: 'echo "test"',
|
||||
name: 'Test Command',
|
||||
};
|
||||
|
||||
const hookId = manager.addSessionHook(
|
||||
'session-1',
|
||||
HookEventName.PostToolUse,
|
||||
'*',
|
||||
commandHook,
|
||||
);
|
||||
|
||||
expect(hookId).toBeDefined();
|
||||
const hooks = manager.getHooksForEvent(
|
||||
'session-1',
|
||||
HookEventName.PostToolUse,
|
||||
);
|
||||
expect(hooks.length).toBe(1);
|
||||
expect(hooks[0].config.type).toBe(HookType.Command);
|
||||
});
|
||||
|
||||
it('should add an HTTP hook', () => {
|
||||
const httpHook: HttpHookConfig = {
|
||||
type: HookType.Http,
|
||||
url: 'https://api.example.com/hook',
|
||||
name: 'Test HTTP',
|
||||
};
|
||||
|
||||
const hookId = manager.addSessionHook(
|
||||
'session-1',
|
||||
HookEventName.PostToolUse,
|
||||
'Write',
|
||||
httpHook,
|
||||
);
|
||||
|
||||
expect(hookId).toBeDefined();
|
||||
const hooks = manager.getHooksForEvent(
|
||||
'session-1',
|
||||
HookEventName.PostToolUse,
|
||||
);
|
||||
expect(hooks.length).toBe(1);
|
||||
expect(hooks[0].config.type).toBe(HookType.Http);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFunctionHook', () => {
|
||||
it('should remove hook by ID', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
const hookId = manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
const removed = manager.removeFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
hookId,
|
||||
);
|
||||
|
||||
expect(removed).toBe(true);
|
||||
expect(manager.hasSessionHooks('session-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-existent hook', () => {
|
||||
const removed = manager.removeFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'non-existent',
|
||||
);
|
||||
|
||||
expect(removed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeHook', () => {
|
||||
it('should remove hook by ID across all events', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
const hookId = manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
const removed = manager.removeHook('session-1', hookId);
|
||||
|
||||
expect(removed).toBe(true);
|
||||
expect(manager.hasSessionHooks('session-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHooksForEvent', () => {
|
||||
it('should return hooks for specific event', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PostToolUse,
|
||||
'*',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
const preToolHooks = manager.getHooksForEvent(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
);
|
||||
const postToolHooks = manager.getHooksForEvent(
|
||||
'session-1',
|
||||
HookEventName.PostToolUse,
|
||||
);
|
||||
|
||||
expect(preToolHooks.length).toBe(1);
|
||||
expect(postToolHooks.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent session', () => {
|
||||
const hooks = manager.getHooksForEvent(
|
||||
'non-existent',
|
||||
HookEventName.PreToolUse,
|
||||
);
|
||||
expect(hooks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMatchingHooks', () => {
|
||||
it('should match exact tool name', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
const matching = manager.getMatchingHooks(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
);
|
||||
|
||||
expect(matching.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should match wildcard *', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'*',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
const matching = manager.getMatchingHooks(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'AnyTool',
|
||||
);
|
||||
|
||||
expect(matching.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should match pipe-separated alternatives', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Write|Edit|Read',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
expect(
|
||||
manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Write')
|
||||
.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Edit')
|
||||
.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Read')
|
||||
.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
manager.getMatchingHooks(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Delete',
|
||||
).length,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('should not match different tool name', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
const matching = manager.getMatchingHooks(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Write',
|
||||
);
|
||||
|
||||
expect(matching.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSessionHooks', () => {
|
||||
it('should return true when session has hooks', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
expect(manager.hasSessionHooks('session-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when session has no hooks', () => {
|
||||
expect(manager.hasSessionHooks('session-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false after all hooks removed', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
const hookId = manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
manager.removeHook('session-1', hookId);
|
||||
|
||||
expect(manager.hasSessionHooks('session-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSessionHooks', () => {
|
||||
it('should clear all hooks for a session', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PostToolUse,
|
||||
'*',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
manager.clearSessionHooks('session-1');
|
||||
|
||||
expect(manager.hasSessionHooks('session-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not affect other sessions', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-2',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
manager.clearSessionHooks('session-1');
|
||||
|
||||
expect(manager.hasSessionHooks('session-1')).toBe(false);
|
||||
expect(manager.hasSessionHooks('session-2')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveSessions', () => {
|
||||
it('should return all session IDs with hooks', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-2',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
const sessions = manager.getActiveSessions();
|
||||
expect(sessions).toContain('session-1');
|
||||
expect(sessions).toContain('session-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHookCount', () => {
|
||||
it('should return correct hook count', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PostToolUse,
|
||||
'*',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
expect(manager.getHookCount('session-1')).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 0 for non-existent session', () => {
|
||||
expect(manager.getHookCount('non-existent')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex matcher support', () => {
|
||||
it('should match using regex pattern', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'^Bash.*',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
expect(
|
||||
manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Bash')
|
||||
.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
manager.getMatchingHooks(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'BashAction',
|
||||
).length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Write')
|
||||
.length,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('should match using regex with anchors', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'^(Write|Edit)$',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
expect(
|
||||
manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Write')
|
||||
.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Edit')
|
||||
.length,
|
||||
).toBe(1);
|
||||
// Should not match WriteOrEdit because of anchors
|
||||
expect(
|
||||
manager.getMatchingHooks(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'WriteOrEdit',
|
||||
).length,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('should fallback to exact match for invalid regex', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
// Invalid regex pattern - unclosed bracket
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'[invalid',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
// Should fallback to exact match
|
||||
expect(
|
||||
manager.getMatchingHooks(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'[invalid',
|
||||
).length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
manager.getMatchingHooks('session-1', HookEventName.PreToolUse, 'Bash')
|
||||
.length,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('skillRoot support', () => {
|
||||
it('should store skillRoot in hook entry', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
{ skillRoot: '/path/to/skill' },
|
||||
);
|
||||
|
||||
const hooks = manager.getMatchingHooks(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
);
|
||||
|
||||
expect(hooks.length).toBe(1);
|
||||
expect(hooks[0].skillRoot).toBe('/path/to/skill');
|
||||
});
|
||||
|
||||
it('should work without skillRoot', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Test error',
|
||||
);
|
||||
|
||||
const hooks = manager.getMatchingHooks(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
);
|
||||
|
||||
expect(hooks.length).toBe(1);
|
||||
expect(hooks[0].skillRoot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should filter hooks by skillRoot', () => {
|
||||
const callback1 = vi.fn().mockResolvedValue({ continue: true });
|
||||
const callback2 = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback1,
|
||||
'Error 1',
|
||||
{ skillRoot: '/skill-a' },
|
||||
);
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback2,
|
||||
'Error 2',
|
||||
{ skillRoot: '/skill-b' },
|
||||
);
|
||||
|
||||
const hooks = manager.getMatchingHooks(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
);
|
||||
|
||||
expect(hooks.length).toBe(2);
|
||||
expect(hooks[0].skillRoot).toBe('/skill-a');
|
||||
expect(hooks[1].skillRoot).toBe('/skill-b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllSessionHooks', () => {
|
||||
it('should return empty array for non-existent session', () => {
|
||||
const hooks = manager.getAllSessionHooks('non-existent-session');
|
||||
expect(hooks).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all hooks across all events', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Error',
|
||||
);
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PostToolUse,
|
||||
'Write',
|
||||
callback,
|
||||
'Error',
|
||||
);
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.Stop,
|
||||
'',
|
||||
callback,
|
||||
'Error',
|
||||
);
|
||||
|
||||
const hooks = manager.getAllSessionHooks('session-1');
|
||||
|
||||
expect(hooks).toHaveLength(3);
|
||||
expect(hooks.map((h) => h.eventName).sort()).toEqual([
|
||||
HookEventName.PostToolUse,
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.Stop,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include session hooks with skillRoot', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Error',
|
||||
{ skillRoot: '/my-skill' },
|
||||
);
|
||||
|
||||
const hooks = manager.getAllSessionHooks('session-1');
|
||||
|
||||
expect(hooks).toHaveLength(1);
|
||||
expect(hooks[0].skillRoot).toBe('/my-skill');
|
||||
});
|
||||
|
||||
it('should return copy of hooks array', () => {
|
||||
const callback = vi.fn().mockResolvedValue({ continue: true });
|
||||
|
||||
manager.addFunctionHook(
|
||||
'session-1',
|
||||
HookEventName.PreToolUse,
|
||||
'Bash',
|
||||
callback,
|
||||
'Error',
|
||||
);
|
||||
|
||||
const hooks1 = manager.getAllSessionHooks('session-1');
|
||||
const hooks2 = manager.getAllSessionHooks('session-1');
|
||||
|
||||
expect(hooks1).not.toBe(hooks2); // Different array references
|
||||
expect(hooks1).toEqual(hooks2); // Same content
|
||||
});
|
||||
});
|
||||
});
|
||||
369
packages/core/src/hooks/sessionHooksManager.ts
Normal file
369
packages/core/src/hooks/sessionHooksManager.ts
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import type {
|
||||
HookEventName,
|
||||
CommandHookConfig,
|
||||
HttpHookConfig,
|
||||
FunctionHookConfig,
|
||||
FunctionHookCallback,
|
||||
HookConfig,
|
||||
HookExecutionResult,
|
||||
} from './types.js';
|
||||
import { HookType } from './types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SESSION_HOOKS_MANAGER');
|
||||
|
||||
/**
|
||||
* Generate a unique hook ID
|
||||
*/
|
||||
function generateHookId(): string {
|
||||
return `session_hook_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session hook entry with matcher and configuration
|
||||
*/
|
||||
export interface SessionHookEntry {
|
||||
hookId: string;
|
||||
eventName: HookEventName;
|
||||
matcher: string;
|
||||
config: HookConfig;
|
||||
sequential?: boolean;
|
||||
/** Optional skill root path for skill-scoped hooks */
|
||||
skillRoot?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session hooks storage per session
|
||||
*/
|
||||
interface SessionHooksStorage {
|
||||
hooks: Map<HookEventName, SessionHookEntry[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session Hooks Manager - manages hooks registered at runtime for specific sessions
|
||||
* Used primarily for SDK integration where hooks are registered programmatically
|
||||
*/
|
||||
export class SessionHooksManager {
|
||||
private readonly sessions: Map<string, SessionHooksStorage> = new Map();
|
||||
|
||||
/**
|
||||
* Get or create session storage
|
||||
*/
|
||||
private getSessionStorage(sessionId: string): SessionHooksStorage {
|
||||
let storage = this.sessions.get(sessionId);
|
||||
if (!storage) {
|
||||
storage = { hooks: new Map() };
|
||||
this.sessions.set(sessionId, storage);
|
||||
}
|
||||
return storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a function hook for a session
|
||||
* @param sessionId Session ID
|
||||
* @param event Hook event name
|
||||
* @param matcher Matcher pattern (e.g., 'Bash', '*', 'Write|Edit', or regex)
|
||||
* @param callback Function callback to execute
|
||||
* @param errorMessage Error message to display on failure
|
||||
* @param options Additional options
|
||||
* @returns Hook ID for later removal
|
||||
*/
|
||||
addFunctionHook(
|
||||
sessionId: string,
|
||||
event: HookEventName,
|
||||
matcher: string,
|
||||
callback: FunctionHookCallback,
|
||||
errorMessage: string,
|
||||
options?: {
|
||||
timeout?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
statusMessage?: string;
|
||||
onHookSuccess?: (result: HookExecutionResult) => void;
|
||||
skillRoot?: string;
|
||||
},
|
||||
): string {
|
||||
const hookId = options?.id || generateHookId();
|
||||
|
||||
const config: FunctionHookConfig = {
|
||||
type: HookType.Function,
|
||||
id: hookId,
|
||||
name: options?.name,
|
||||
description: options?.description,
|
||||
timeout: options?.timeout,
|
||||
callback,
|
||||
errorMessage,
|
||||
statusMessage: options?.statusMessage,
|
||||
onHookSuccess: options?.onHookSuccess,
|
||||
};
|
||||
|
||||
const entry: SessionHookEntry = {
|
||||
hookId,
|
||||
eventName: event,
|
||||
matcher,
|
||||
config,
|
||||
skillRoot: options?.skillRoot,
|
||||
};
|
||||
|
||||
const storage = this.getSessionStorage(sessionId);
|
||||
const eventHooks = storage.hooks.get(event) || [];
|
||||
eventHooks.push(entry);
|
||||
storage.hooks.set(event, eventHooks);
|
||||
|
||||
debugLogger.debug(
|
||||
`Added function hook ${hookId} for session ${sessionId} on event ${event}`,
|
||||
);
|
||||
|
||||
return hookId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a command or HTTP hook for a session
|
||||
* @param sessionId Session ID
|
||||
* @param event Hook event name
|
||||
* @param matcher Matcher pattern
|
||||
* @param hook Hook configuration (command or HTTP)
|
||||
* @param options Additional options
|
||||
*/
|
||||
addSessionHook(
|
||||
sessionId: string,
|
||||
event: HookEventName,
|
||||
matcher: string,
|
||||
hook: CommandHookConfig | HttpHookConfig,
|
||||
options?: { sequential?: boolean; skillRoot?: string },
|
||||
): string {
|
||||
const hookId = generateHookId();
|
||||
|
||||
const entry: SessionHookEntry = {
|
||||
hookId,
|
||||
eventName: event,
|
||||
matcher,
|
||||
config: hook,
|
||||
sequential: options?.sequential,
|
||||
skillRoot: options?.skillRoot,
|
||||
};
|
||||
|
||||
const storage = this.getSessionStorage(sessionId);
|
||||
const eventHooks = storage.hooks.get(event) || [];
|
||||
eventHooks.push(entry);
|
||||
storage.hooks.set(event, eventHooks);
|
||||
|
||||
debugLogger.debug(
|
||||
`Added session hook ${hookId} for session ${sessionId} on event ${event}`,
|
||||
);
|
||||
|
||||
return hookId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a function hook by ID
|
||||
* @param sessionId Session ID
|
||||
* @param event Hook event name
|
||||
* @param hookId Hook ID to remove
|
||||
* @returns True if hook was found and removed
|
||||
*/
|
||||
removeFunctionHook(
|
||||
sessionId: string,
|
||||
event: HookEventName,
|
||||
hookId: string,
|
||||
): boolean {
|
||||
const storage = this.sessions.get(sessionId);
|
||||
if (!storage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventHooks = storage.hooks.get(event);
|
||||
if (!eventHooks) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = eventHooks.findIndex((entry) => entry.hookId === hookId);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
eventHooks.splice(index, 1);
|
||||
debugLogger.debug(
|
||||
`Removed hook ${hookId} from session ${sessionId} on event ${event}`,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a hook by ID (searches all events)
|
||||
* @param sessionId Session ID
|
||||
* @param hookId Hook ID to remove
|
||||
* @returns True if hook was found and removed
|
||||
*/
|
||||
removeHook(sessionId: string, hookId: string): boolean {
|
||||
const storage = this.sessions.get(sessionId);
|
||||
if (!storage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [event, eventHooks] of storage.hooks.entries()) {
|
||||
const index = eventHooks.findIndex((entry) => entry.hookId === hookId);
|
||||
if (index !== -1) {
|
||||
eventHooks.splice(index, 1);
|
||||
debugLogger.debug(
|
||||
`Removed hook ${hookId} from session ${sessionId} on event ${event}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hooks for a session and event
|
||||
* @param sessionId Session ID
|
||||
* @param event Hook event name
|
||||
* @returns Array of session hook entries
|
||||
*/
|
||||
getHooksForEvent(
|
||||
sessionId: string,
|
||||
event: HookEventName,
|
||||
): SessionHookEntry[] {
|
||||
const storage = this.sessions.get(sessionId);
|
||||
if (!storage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return storage.hooks.get(event) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hooks that match a specific tool/target
|
||||
* @param sessionId Session ID
|
||||
* @param event Hook event name
|
||||
* @param target Target to match (e.g., tool name)
|
||||
* @returns Array of matching hook entries
|
||||
*/
|
||||
getMatchingHooks(
|
||||
sessionId: string,
|
||||
event: HookEventName,
|
||||
target: string,
|
||||
): SessionHookEntry[] {
|
||||
const hooks = this.getHooksForEvent(sessionId, event);
|
||||
return hooks.filter((entry) => this.matchesPattern(entry.matcher, target));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a target matches a pattern
|
||||
* Supports: exact match, '*' wildcard, '|' for alternatives, regex syntax
|
||||
*
|
||||
* Matching priority:
|
||||
* 1. '*' - matches everything
|
||||
* 2. Pipe-separated alternatives (e.g., 'Write|Edit|Read')
|
||||
* 3. Regex syntax (e.g., '^Bash.*', 'Write|Edit')
|
||||
* 4. Exact match (fallback)
|
||||
*/
|
||||
private matchesPattern(pattern: string, target: string): boolean {
|
||||
if (pattern === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle pipe-separated alternatives
|
||||
if (
|
||||
pattern.includes('|') &&
|
||||
!pattern.startsWith('^') &&
|
||||
!pattern.startsWith('(')
|
||||
) {
|
||||
const alternatives = pattern.split('|').map((s) => s.trim());
|
||||
return alternatives.some((alt) => this.matchesPattern(alt, target));
|
||||
}
|
||||
|
||||
// Try regex match
|
||||
try {
|
||||
const regex = new RegExp(`^${pattern}$`);
|
||||
return regex.test(target);
|
||||
} catch {
|
||||
// Invalid regex, fall back to exact match
|
||||
debugLogger.debug(
|
||||
`Invalid regex pattern "${pattern}", falling back to exact match`,
|
||||
);
|
||||
}
|
||||
|
||||
// Exact match (fallback)
|
||||
return pattern === target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session has any hooks registered
|
||||
* @param sessionId Session ID
|
||||
* @returns True if session has hooks
|
||||
*/
|
||||
hasSessionHooks(sessionId: string): boolean {
|
||||
const storage = this.sessions.get(sessionId);
|
||||
if (!storage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const eventHooks of storage.hooks.values()) {
|
||||
if (eventHooks.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all hooks for a session
|
||||
* @param sessionId Session ID
|
||||
*/
|
||||
clearSessionHooks(sessionId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
debugLogger.debug(`Cleared all hooks for session ${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all session IDs with registered hooks
|
||||
*/
|
||||
getActiveSessions(): string[] {
|
||||
return Array.from(this.sessions.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hook count for a session
|
||||
*/
|
||||
getHookCount(sessionId: string): number {
|
||||
const storage = this.sessions.get(sessionId);
|
||||
if (!storage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const eventHooks of storage.hooks.values()) {
|
||||
count += eventHooks.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hooks for a session across all events
|
||||
* @param sessionId Session ID
|
||||
* @returns Array of all session hook entries
|
||||
*/
|
||||
getAllSessionHooks(sessionId: string): SessionHookEntry[] {
|
||||
const storage = this.sessions.get(sessionId);
|
||||
if (!storage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allHooks: SessionHookEntry[] = [];
|
||||
for (const eventHooks of storage.hooks.values()) {
|
||||
allHooks.push(...eventHooks);
|
||||
}
|
||||
return allHooks;
|
||||
}
|
||||
}
|
||||
159
packages/core/src/hooks/ssrfGuard.test.ts
Normal file
159
packages/core/src/hooks/ssrfGuard.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isBlockedAddress, ssrfGuardedLookup } from './ssrfGuard.js';
|
||||
|
||||
function lookupAsync(
|
||||
hostname: string,
|
||||
options?: { all?: boolean },
|
||||
): Promise<{
|
||||
err: Error | null;
|
||||
address: string | Array<{ address: string; family: number }>;
|
||||
family?: number;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
ssrfGuardedLookup(hostname, options ?? {}, (err, address, family) => {
|
||||
resolve({ err, address, family });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('ssrfGuard', () => {
|
||||
describe('isBlockedAddress', () => {
|
||||
it('should block 10.0.0.0/8 (private)', () => {
|
||||
expect(isBlockedAddress('10.0.0.1')).toBe(true);
|
||||
expect(isBlockedAddress('10.255.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block 172.16.0.0/12 (private)', () => {
|
||||
expect(isBlockedAddress('172.16.0.1')).toBe(true);
|
||||
expect(isBlockedAddress('172.31.255.255')).toBe(true);
|
||||
expect(isBlockedAddress('172.15.255.255')).toBe(false);
|
||||
expect(isBlockedAddress('172.32.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('should block 192.168.0.0/16 (private)', () => {
|
||||
expect(isBlockedAddress('192.168.0.1')).toBe(true);
|
||||
expect(isBlockedAddress('192.168.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block 169.254.0.0/16 (link-local)', () => {
|
||||
expect(isBlockedAddress('169.254.169.254')).toBe(true);
|
||||
expect(isBlockedAddress('169.254.0.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block 100.64.0.0/10 (CGNAT)', () => {
|
||||
expect(isBlockedAddress('100.64.0.0')).toBe(true);
|
||||
expect(isBlockedAddress('100.100.100.200')).toBe(true);
|
||||
expect(isBlockedAddress('100.127.255.255')).toBe(true);
|
||||
expect(isBlockedAddress('100.63.255.255')).toBe(false);
|
||||
expect(isBlockedAddress('100.128.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('should block 0.0.0.0/8', () => {
|
||||
expect(isBlockedAddress('0.0.0.0')).toBe(true);
|
||||
expect(isBlockedAddress('0.255.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('should ALLOW 127.0.0.0/8 (loopback) for local dev', () => {
|
||||
expect(isBlockedAddress('127.0.0.1')).toBe(false);
|
||||
expect(isBlockedAddress('127.0.0.2')).toBe(false);
|
||||
expect(isBlockedAddress('127.255.255.255')).toBe(false);
|
||||
});
|
||||
|
||||
it('should ALLOW public IPs', () => {
|
||||
expect(isBlockedAddress('8.8.8.8')).toBe(false);
|
||||
expect(isBlockedAddress('1.1.1.1')).toBe(false);
|
||||
expect(isBlockedAddress('203.0.113.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should ALLOW ::1 (IPv6 loopback)', () => {
|
||||
expect(isBlockedAddress('::1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should block :: (unspecified)', () => {
|
||||
expect(isBlockedAddress('::')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block IPv6 unique local (fc00::/7)', () => {
|
||||
expect(isBlockedAddress('fc00::1')).toBe(true);
|
||||
expect(isBlockedAddress('fd00::1')).toBe(true);
|
||||
expect(isBlockedAddress('fe00::1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should block IPv6 link-local (fe80::/10)', () => {
|
||||
expect(isBlockedAddress('fe80::1')).toBe(true);
|
||||
expect(isBlockedAddress('febf::1')).toBe(true);
|
||||
expect(isBlockedAddress('fec0::1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should block IPv4-mapped IPv6 in private range', () => {
|
||||
// ::ffff:a9fe:a9fe = 169.254.169.254
|
||||
expect(isBlockedAddress('::ffff:a9fe:a9fe')).toBe(true);
|
||||
// ::ffff:c0a8:0101 = 192.168.1.1
|
||||
expect(isBlockedAddress('::ffff:c0a8:101')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow IPv4-mapped IPv6 in loopback range', () => {
|
||||
// ::ffff:7f00:1 = 127.0.0.1
|
||||
expect(isBlockedAddress('::ffff:7f00:1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-IP hostnames', () => {
|
||||
expect(isBlockedAddress('api.example.com')).toBe(false);
|
||||
expect(isBlockedAddress('localhost')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ssrfGuardedLookup', () => {
|
||||
it('should block IP literals in private ranges', async () => {
|
||||
const { err } = await lookupAsync('169.254.169.254');
|
||||
expect(err).toBeTruthy();
|
||||
expect((err as NodeJS.ErrnoException).code).toBe(
|
||||
'ERR_HTTP_HOOK_BLOCKED_ADDRESS',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow IP literals in loopback range', async () => {
|
||||
const { err, address, family } = await lookupAsync('127.0.0.1');
|
||||
expect(err).toBeNull();
|
||||
expect(address).toBe('127.0.0.1');
|
||||
expect(family).toBe(4);
|
||||
});
|
||||
|
||||
it('should allow ::1 (IPv6 loopback)', async () => {
|
||||
const { err, address, family } = await lookupAsync('::1');
|
||||
expect(err).toBeNull();
|
||||
expect(address).toBe('::1');
|
||||
expect(family).toBe(6);
|
||||
});
|
||||
|
||||
it('should return all addresses when all=true', async () => {
|
||||
const { err, address } = await lookupAsync('127.0.0.1', { all: true });
|
||||
expect(err).toBeNull();
|
||||
expect(Array.isArray(address)).toBe(true);
|
||||
expect((address as Array<{ address: string }>).length).toBe(1);
|
||||
});
|
||||
|
||||
it('should resolve DNS and validate IPs for hostnames', async () => {
|
||||
// localhost typically resolves to 127.0.0.1 which is allowed
|
||||
const { err, address } = await lookupAsync('localhost');
|
||||
expect(err).toBeNull();
|
||||
expect(address).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should block localhost.localdomain', async () => {
|
||||
// This is in BLOCKED_HOSTS list
|
||||
const { err } = await lookupAsync('localhost.localdomain');
|
||||
// This hostname may not resolve, but the SSRF check happens after DNS
|
||||
// Since it's not an IP literal, DNS resolution will be attempted
|
||||
// The actual blocking depends on whether it resolves to a private IP
|
||||
// For this test, we just check the function doesn't crash
|
||||
expect(err).toBeDefined(); // Will likely fail DNS lookup
|
||||
});
|
||||
});
|
||||
});
|
||||
286
packages/core/src/hooks/ssrfGuard.ts
Normal file
286
packages/core/src/hooks/ssrfGuard.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { isIP } from 'net';
|
||||
import * as dns from 'dns';
|
||||
|
||||
/**
|
||||
* SSRF guard for HTTP hooks.
|
||||
*
|
||||
* Aligned with Claude Code's ssrfGuard.ts behavior.
|
||||
*
|
||||
* Blocks private, link-local, and other non-routable address ranges to prevent
|
||||
* project-configured HTTP hooks from reaching cloud metadata endpoints
|
||||
* (169.254.169.254) or internal infrastructure.
|
||||
*
|
||||
* Loopback (127.0.0.0/8, ::1) is intentionally ALLOWED — local dev policy
|
||||
* servers are a primary HTTP hook use case.
|
||||
*
|
||||
* NOTE: Node.js native `fetch` does not support a custom `lookup` option
|
||||
* (unlike axios). This module performs DNS validation before the request.
|
||||
* There is a small race window between validation and connection where a
|
||||
* sophisticated DNS rebinding attack could occur. For most threat models
|
||||
* this is acceptable. For higher security, use a proxy or switch to axios.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns true if the address is in a range that HTTP hooks should not reach.
|
||||
*
|
||||
* Blocked IPv4:
|
||||
* 0.0.0.0/8 "this" network
|
||||
* 10.0.0.0/8 private
|
||||
* 100.64.0.0/10 shared address space / CGNAT (some cloud metadata, e.g. Alibaba 100.100.100.200)
|
||||
* 169.254.0.0/16 link-local (cloud metadata)
|
||||
* 172.16.0.0/12 private
|
||||
* 192.168.0.0/16 private
|
||||
*
|
||||
* Blocked IPv6:
|
||||
* :: unspecified
|
||||
* fc00::/7 unique local
|
||||
* fe80::/10 link-local
|
||||
* ::ffff:<v4> mapped IPv4 in a blocked range
|
||||
*
|
||||
* Allowed (returns false):
|
||||
* 127.0.0.0/8 loopback (local dev hooks)
|
||||
* ::1 loopback
|
||||
* everything else
|
||||
*/
|
||||
export function isBlockedAddress(address: string): boolean {
|
||||
const v = isIP(address);
|
||||
if (v === 4) {
|
||||
return isBlockedV4(address);
|
||||
}
|
||||
if (v === 6) {
|
||||
return isBlockedV6(address);
|
||||
}
|
||||
// Not a valid IP literal — let the real DNS path handle it
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBlockedV4(address: string): boolean {
|
||||
const parts = address.split('.').map(Number);
|
||||
const [a, b] = parts;
|
||||
if (
|
||||
parts.length !== 4 ||
|
||||
a === undefined ||
|
||||
b === undefined ||
|
||||
parts.some((n) => Number.isNaN(n))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Loopback explicitly allowed
|
||||
if (a === 127) return false;
|
||||
|
||||
// 0.0.0.0/8
|
||||
if (a === 0) return true;
|
||||
// 10.0.0.0/8
|
||||
if (a === 10) return true;
|
||||
// 169.254.0.0/16 — link-local, cloud metadata
|
||||
if (a === 169 && b === 254) return true;
|
||||
// 172.16.0.0/12
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
// 100.64.0.0/10 — shared address space (RFC 6598, CGNAT)
|
||||
if (a === 100 && b >= 64 && b <= 127) return true;
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBlockedV6(address: string): boolean {
|
||||
const lower = address.toLowerCase();
|
||||
|
||||
// ::1 loopback explicitly allowed
|
||||
if (lower === '::1') return false;
|
||||
|
||||
// :: unspecified
|
||||
if (lower === '::') return true;
|
||||
|
||||
// IPv4-mapped IPv6 (0:0:0:0:0:ffff:X:Y in any representation).
|
||||
// Extract the embedded IPv4 address and delegate to the v4 check.
|
||||
const mappedV4 = extractMappedIPv4(lower);
|
||||
if (mappedV4 !== null) {
|
||||
return isBlockedV4(mappedV4);
|
||||
}
|
||||
|
||||
// fc00::/7 — unique local addresses (fc00:: through fdff::)
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// fe80::/10 — link-local. The /10 means fe80 through febf.
|
||||
const firstHextet = lower.split(':')[0];
|
||||
if (
|
||||
firstHextet &&
|
||||
firstHextet.length >= 3 &&
|
||||
firstHextet >= 'fe80' &&
|
||||
firstHextet <= 'febf'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand `::` and optional trailing dotted-decimal so an IPv6 address is
|
||||
* represented as exactly 8 hex groups. Returns null if expansion is not
|
||||
* well-formed.
|
||||
*/
|
||||
function expandIPv6Groups(addr: string): number[] | null {
|
||||
// Handle trailing dotted-decimal IPv4 (e.g. ::ffff:169.254.169.254).
|
||||
let tailHextets: number[] = [];
|
||||
if (addr.includes('.')) {
|
||||
const lastColon = addr.lastIndexOf(':');
|
||||
const v4 = addr.slice(lastColon + 1);
|
||||
addr = addr.slice(0, lastColon);
|
||||
const octets = v4.split('.').map(Number);
|
||||
if (
|
||||
octets.length !== 4 ||
|
||||
octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
tailHextets = [
|
||||
(octets[0]! << 8) | octets[1]!,
|
||||
(octets[2]! << 8) | octets[3]!,
|
||||
];
|
||||
}
|
||||
|
||||
// Expand `::` (at most one) into the right number of zero groups.
|
||||
const dbl = addr.indexOf('::');
|
||||
let head: string[];
|
||||
let tail: string[];
|
||||
if (dbl === -1) {
|
||||
head = addr.split(':');
|
||||
tail = [];
|
||||
} else {
|
||||
const headStr = addr.slice(0, dbl);
|
||||
const tailStr = addr.slice(dbl + 2);
|
||||
head = headStr === '' ? [] : headStr.split(':');
|
||||
tail = tailStr === '' ? [] : tailStr.split(':');
|
||||
}
|
||||
|
||||
const target = 8 - tailHextets.length;
|
||||
const fill = target - head.length - tail.length;
|
||||
if (fill < 0) return null;
|
||||
|
||||
const hex = [...head, ...new Array<string>(fill).fill('0'), ...tail];
|
||||
const nums = hex.map((h) => parseInt(h, 16));
|
||||
if (nums.some((n) => Number.isNaN(n) || n < 0 || n > 0xffff)) {
|
||||
return null;
|
||||
}
|
||||
nums.push(...tailHextets);
|
||||
return nums.length === 8 ? nums : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the embedded IPv4 address from an IPv4-mapped IPv6 address
|
||||
* (0:0:0:0:0:ffff:X:Y) in any valid representation.
|
||||
*/
|
||||
function extractMappedIPv4(addr: string): string | null {
|
||||
const g = expandIPv6Groups(addr);
|
||||
if (!g) return null;
|
||||
// IPv4-mapped: first 80 bits zero, next 16 bits ffff, last 32 bits = IPv4
|
||||
if (
|
||||
g[0] === 0 &&
|
||||
g[1] === 0 &&
|
||||
g[2] === 0 &&
|
||||
g[3] === 0 &&
|
||||
g[4] === 0 &&
|
||||
g[5] === 0xffff
|
||||
) {
|
||||
const hi = g[6]!;
|
||||
const lo = g[7]!;
|
||||
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dns.lookup-compatible function that resolves a hostname and rejects
|
||||
* addresses in blocked ranges. Used as a custom lookup to validate the
|
||||
* resolved IP before connecting.
|
||||
*/
|
||||
export function ssrfGuardedLookup(
|
||||
hostname: string,
|
||||
options: { all?: boolean },
|
||||
callback: (
|
||||
err: Error | null,
|
||||
address: string | Array<{ address: string; family: number }>,
|
||||
family?: number,
|
||||
) => void,
|
||||
): void {
|
||||
const wantsAll = 'all' in options && options.all === true;
|
||||
|
||||
// If hostname is already an IP literal, validate it directly.
|
||||
const ipVersion = isIP(hostname);
|
||||
if (ipVersion !== 0) {
|
||||
if (isBlockedAddress(hostname)) {
|
||||
callback(ssrfError(hostname, hostname), '');
|
||||
return;
|
||||
}
|
||||
const family = ipVersion === 6 ? 6 : 4;
|
||||
if (wantsAll) {
|
||||
callback(null, [{ address: hostname, family }]);
|
||||
} else {
|
||||
callback(null, hostname, family);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve DNS and validate all returned IPs.
|
||||
dns.promises
|
||||
.lookup(hostname, { all: true })
|
||||
.then((addresses) => {
|
||||
for (const { address } of addresses) {
|
||||
if (isBlockedAddress(address)) {
|
||||
callback(ssrfError(hostname, address), '');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const first = addresses[0];
|
||||
if (!first) {
|
||||
callback(
|
||||
Object.assign(new Error(`ENOTFOUND ${hostname}`), {
|
||||
code: 'ENOTFOUND',
|
||||
hostname,
|
||||
}),
|
||||
'',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const family = first.family === 6 ? 6 : 4;
|
||||
if (wantsAll) {
|
||||
callback(
|
||||
null,
|
||||
addresses.map((a) => ({
|
||||
address: a.address,
|
||||
family: a.family === 6 ? 6 : 4,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
callback(null, first.address, family);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
callback(err, '');
|
||||
});
|
||||
}
|
||||
|
||||
function ssrfError(hostname: string, address: string): NodeJS.ErrnoException {
|
||||
const err = new Error(
|
||||
`HTTP hook blocked: ${hostname} resolves to ${address} (private/link-local address). Loopback (127.0.0.1, ::1) is allowed for local dev.`,
|
||||
);
|
||||
return Object.assign(err, {
|
||||
code: 'ERR_HTTP_HOOK_BLOCKED_ADDRESS',
|
||||
hostname,
|
||||
address,
|
||||
});
|
||||
}
|
||||
|
|
@ -82,7 +82,13 @@ export class TrustedHooksManager {
|
|||
const key = getHookKey(hook);
|
||||
if (!trustedKeys.has(key)) {
|
||||
// Return friendly name or command
|
||||
untrusted.push(hook.name || hook.command || 'unknown-hook');
|
||||
const hookIdentifier =
|
||||
hook.name ||
|
||||
(hook.type === 'command'
|
||||
? (hook as { command?: string }).command
|
||||
: undefined) ||
|
||||
'unknown-hook';
|
||||
untrusted.push(hookIdentifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
const debugLogger = createDebugLogger('TRUSTED_HOOKS');
|
||||
|
|
@ -12,6 +13,7 @@ export enum HooksConfigSource {
|
|||
User = 'user',
|
||||
System = 'system',
|
||||
Extensions = 'extensions',
|
||||
Session = 'session',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -54,7 +56,7 @@ export enum HookEventName {
|
|||
export const HOOKS_CONFIG_FIELDS = ['enabled', 'disabled', 'notifications'];
|
||||
|
||||
/**
|
||||
* Hook configuration entry
|
||||
* Hook configuration entry for command hooks
|
||||
*/
|
||||
export interface CommandHookConfig {
|
||||
type: HookType.Command;
|
||||
|
|
@ -64,9 +66,88 @@ export interface CommandHookConfig {
|
|||
timeout?: number;
|
||||
source?: HooksConfigSource;
|
||||
env?: Record<string, string>;
|
||||
async?: boolean;
|
||||
shell?: 'bash' | 'powershell';
|
||||
/** Custom status message to display while hook is executing */
|
||||
statusMessage?: string;
|
||||
}
|
||||
|
||||
export type HookConfig = CommandHookConfig;
|
||||
/**
|
||||
* Hook configuration entry for HTTP hooks
|
||||
*/
|
||||
export interface HttpHookConfig {
|
||||
type: HookType.Http;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
allowedEnvVars?: string[];
|
||||
timeout?: number;
|
||||
if?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
statusMessage?: string;
|
||||
once?: boolean;
|
||||
source?: HooksConfigSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook execution outcome - describes the result of hook execution
|
||||
*/
|
||||
export type HookExecutionOutcome =
|
||||
| 'success' // Hook executed successfully
|
||||
| 'blocking' // Hook blocked the operation
|
||||
| 'non_blocking_error' // Hook failed but doesn't block
|
||||
| 'cancelled'; // Hook was cancelled/aborted
|
||||
|
||||
/**
|
||||
* Context provided to function hooks for state access
|
||||
*/
|
||||
export interface FunctionHookContext {
|
||||
/** Optional messages for conversation context */
|
||||
messages?: Array<Record<string, unknown>>;
|
||||
/** Optional tool use ID for关联 to specific tool call */
|
||||
toolUseID?: string;
|
||||
/** Optional abort signal for cancellation */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function hook callback type
|
||||
* Supports both simple boolean semantics and complex HookOutput semantics
|
||||
* - Return boolean: true=success, false=blocking error
|
||||
* - Return HookOutput: for advanced control over hook behavior
|
||||
* - Return undefined: treated as {continue: true} (success)
|
||||
*/
|
||||
export type FunctionHookCallback = (
|
||||
input: HookInput,
|
||||
context?: FunctionHookContext,
|
||||
) => Promise<HookOutput | boolean | undefined>;
|
||||
|
||||
/**
|
||||
* Hook configuration entry for function hooks (Session Hook specific)
|
||||
*/
|
||||
export interface FunctionHookConfig {
|
||||
type: HookType.Function;
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeout?: number;
|
||||
callback: FunctionHookCallback;
|
||||
errorMessage: string;
|
||||
statusMessage?: string;
|
||||
/** Optional callback invoked on successful hook execution */
|
||||
onHookSuccess?: (result: HookExecutionResult) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages provider callback type for automatically passing conversation history
|
||||
* to function hooks during execution
|
||||
*/
|
||||
export type MessagesProvider = () => Array<Record<string, unknown>> | undefined;
|
||||
|
||||
export type HookConfig =
|
||||
| CommandHookConfig
|
||||
| HttpHookConfig
|
||||
| FunctionHookConfig;
|
||||
|
||||
/**
|
||||
* Hook definition with matcher
|
||||
|
|
@ -82,6 +163,8 @@ export interface HookDefinition {
|
|||
*/
|
||||
export enum HookType {
|
||||
Command = 'command',
|
||||
Http = 'http',
|
||||
Function = 'function',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -89,7 +172,18 @@ export enum HookType {
|
|||
*/
|
||||
export function getHookKey(hook: HookConfig): string {
|
||||
const name = hook.name ?? '';
|
||||
return name ? `${name}:${hook.command}` : hook.command;
|
||||
switch (hook.type) {
|
||||
case HookType.Command:
|
||||
return name ? `${name}:${hook.command}` : hook.command;
|
||||
case HookType.Http:
|
||||
return name ? `${name}:${hook.url}` : hook.url;
|
||||
case HookType.Function:
|
||||
return name
|
||||
? `${name}:${hook.id ?? 'function'}`
|
||||
: (hook.id ?? 'function');
|
||||
default:
|
||||
return name || 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -795,12 +889,15 @@ export interface HookExecutionResult {
|
|||
hookConfig: HookConfig;
|
||||
eventName: HookEventName;
|
||||
success: boolean;
|
||||
/** Execution outcome for finer-grained result handling */
|
||||
outcome?: HookExecutionOutcome;
|
||||
output?: HookOutput;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exitCode?: number;
|
||||
duration: number;
|
||||
error?: Error;
|
||||
isAsync?: boolean; // Indicates if this was an async hook execution
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -811,3 +908,44 @@ export interface HookExecutionPlan {
|
|||
hookConfigs: HookConfig[];
|
||||
sequential: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending async hook information
|
||||
*/
|
||||
export interface PendingAsyncHook {
|
||||
hookId: string;
|
||||
hookName: string;
|
||||
hookEvent: HookEventName;
|
||||
sessionId: string;
|
||||
startTime: number;
|
||||
timeout: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'timeout';
|
||||
output?: HookOutput;
|
||||
error?: Error;
|
||||
/**
|
||||
* Reference to the child process for async command hooks.
|
||||
* Used to terminate the process on timeout or cancellation.
|
||||
*/
|
||||
process?: ChildProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async hook output message
|
||||
*/
|
||||
export interface AsyncHookOutputMessage {
|
||||
type: 'system' | 'info' | 'warning' | 'error';
|
||||
message: string;
|
||||
hookName: string;
|
||||
hookId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending async output collection
|
||||
*/
|
||||
export interface PendingAsyncOutput {
|
||||
messages: AsyncHookOutputMessage[];
|
||||
contexts: string[];
|
||||
}
|
||||
|
|
|
|||
148
packages/core/src/hooks/urlValidator.test.ts
Normal file
148
packages/core/src/hooks/urlValidator.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { UrlValidator, createUrlValidator } from './urlValidator.js';
|
||||
|
||||
describe('UrlValidator', () => {
|
||||
describe('isBlocked', () => {
|
||||
it('should ALLOW 127.0.0.1 for local dev hooks', () => {
|
||||
const validator = new UrlValidator([]);
|
||||
expect(validator.isBlocked('http://127.0.0.1:8080/api')).toBe(false);
|
||||
expect(validator.isBlocked('http://127.0.0.1/api')).toBe(false);
|
||||
expect(validator.isBlocked('http://127.0.0.1:9876/hook')).toBe(false);
|
||||
});
|
||||
|
||||
it('should ALLOW localhost for local dev hooks', () => {
|
||||
const validator = new UrlValidator([]);
|
||||
expect(validator.isBlocked('http://localhost:8080/api')).toBe(false);
|
||||
expect(validator.isBlocked('http://localhost:9876/hook')).toBe(false);
|
||||
});
|
||||
|
||||
it('should block private IP 192.168.x.x', () => {
|
||||
const validator = new UrlValidator([]);
|
||||
expect(validator.isBlocked('http://192.168.1.1/api')).toBe(true);
|
||||
expect(validator.isBlocked('http://192.168.0.100:8080/api')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block private IP 10.x.x.x', () => {
|
||||
const validator = new UrlValidator([]);
|
||||
expect(validator.isBlocked('http://10.0.0.1/api')).toBe(true);
|
||||
expect(validator.isBlocked('http://10.255.255.255/api')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block private IP 172.16.x.x - 172.31.x.x', () => {
|
||||
const validator = new UrlValidator([]);
|
||||
expect(validator.isBlocked('http://172.16.0.1/api')).toBe(true);
|
||||
expect(validator.isBlocked('http://172.31.255.255/api')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block cloud metadata endpoints', () => {
|
||||
const validator = new UrlValidator([]);
|
||||
expect(
|
||||
validator.isBlocked('http://169.254.169.254/latest/meta-data'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validator.isBlocked('http://metadata.google.internal/computeMetadata'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow public URLs', () => {
|
||||
const validator = new UrlValidator([]);
|
||||
expect(validator.isBlocked('https://api.example.com/hook')).toBe(false);
|
||||
expect(validator.isBlocked('https://webhook.site/test')).toBe(false);
|
||||
});
|
||||
|
||||
it('should block invalid URLs', () => {
|
||||
const validator = new UrlValidator([]);
|
||||
expect(validator.isBlocked('not-a-url')).toBe(true);
|
||||
expect(validator.isBlocked('')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowed', () => {
|
||||
it('should allow all URLs when no patterns configured', () => {
|
||||
const validator = new UrlValidator([]);
|
||||
expect(validator.isAllowed('https://any.example.com/api')).toBe(true);
|
||||
});
|
||||
|
||||
it('should match exact URL pattern', () => {
|
||||
const validator = new UrlValidator(['https://api\\.example\\.com/hook']);
|
||||
expect(validator.isAllowed('https://api.example.com/hook')).toBe(true);
|
||||
expect(validator.isAllowed('https://api.example.com/other')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match wildcard pattern', () => {
|
||||
const validator = new UrlValidator(['https://api\\.example\\.com/*']);
|
||||
expect(validator.isAllowed('https://api.example.com/hook')).toBe(true);
|
||||
expect(validator.isAllowed('https://api.example.com/v1/hook')).toBe(true);
|
||||
expect(validator.isAllowed('https://other.example.com/hook')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match multiple patterns', () => {
|
||||
const validator = new UrlValidator([
|
||||
'https://api\\.example\\.com/*',
|
||||
'https://webhook\\.site/*',
|
||||
]);
|
||||
expect(validator.isAllowed('https://api.example.com/hook')).toBe(true);
|
||||
expect(validator.isAllowed('https://webhook.site/test')).toBe(true);
|
||||
expect(validator.isAllowed('https://other.com/hook')).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
const validator = new UrlValidator(['https://API\\.Example\\.COM/*']);
|
||||
expect(validator.isAllowed('https://api.example.com/hook')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should return allowed for valid public URL matching whitelist', () => {
|
||||
const validator = new UrlValidator(['https://api\\.example\\.com/*']);
|
||||
const result = validator.validate('https://api.example.com/hook');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return not allowed for blocked URL (private IP)', () => {
|
||||
const validator = new UrlValidator(['*']);
|
||||
const result = validator.validate('http://192.168.1.1:8080/api');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('SSRF');
|
||||
});
|
||||
|
||||
it('should return allowed for localhost/loopback URLs', () => {
|
||||
const validator = new UrlValidator(['*']);
|
||||
const result1 = validator.validate('http://localhost:8080/api');
|
||||
expect(result1.allowed).toBe(true);
|
||||
const result2 = validator.validate('http://127.0.0.1:9876/hook');
|
||||
expect(result2.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should return not allowed for URL not matching whitelist', () => {
|
||||
const validator = new UrlValidator(['https://api\\.example\\.com/*']);
|
||||
const result = validator.validate('https://other.com/hook');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('does not match');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUrlValidator', () => {
|
||||
it('should create validator with allowed URLs', () => {
|
||||
const validator = createUrlValidator(['https://api\\.example\\.com/*']);
|
||||
expect(validator.isAllowed('https://api.example.com/hook')).toBe(true);
|
||||
});
|
||||
|
||||
it('should create validator with empty array', () => {
|
||||
const validator = createUrlValidator([]);
|
||||
expect(validator.isAllowed('https://any.com/hook')).toBe(true);
|
||||
});
|
||||
|
||||
it('should create validator with undefined', () => {
|
||||
const validator = createUrlValidator(undefined);
|
||||
expect(validator.isAllowed('https://any.com/hook')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
162
packages/core/src/hooks/urlValidator.ts
Normal file
162
packages/core/src/hooks/urlValidator.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { isIPv4, isIPv6 } from 'net';
|
||||
import { isBlockedAddress } from './ssrfGuard.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
const debugLogger = createDebugLogger('URL_VALIDATOR');
|
||||
|
||||
/**
|
||||
* Hostnames that should be blocked for SSRF protection
|
||||
* Note: 'localhost' is intentionally ALLOWED for local dev hooks (matches Claude Code behavior)
|
||||
*/
|
||||
const BLOCKED_HOSTS = [
|
||||
'localhost.localdomain',
|
||||
'ip6-localhost',
|
||||
'ip6-loopback',
|
||||
'metadata.google.internal', // GCP metadata
|
||||
'169.254.169.254', // Cloud metadata (AWS, GCP, Azure)
|
||||
'metadata.azure.internal', // Azure metadata
|
||||
];
|
||||
|
||||
/**
|
||||
* URL validator for HTTP hooks with whitelist and SSRF protection.
|
||||
*
|
||||
* SSRF protection uses the authoritative ssrfGuard.ts module for IP blocking.
|
||||
* This module focuses on URL whitelist validation and hostname blocklist.
|
||||
*/
|
||||
export class UrlValidator {
|
||||
private readonly allowedPatterns: string[];
|
||||
private readonly compiledPatterns: RegExp[];
|
||||
|
||||
/**
|
||||
* Create a new URL validator
|
||||
* @param allowedPatterns - Array of allowed URL patterns (supports * wildcard)
|
||||
*/
|
||||
constructor(allowedPatterns: string[] = []) {
|
||||
this.allowedPatterns = allowedPatterns;
|
||||
this.compiledPatterns = allowedPatterns.map((pattern) =>
|
||||
this.compilePattern(pattern),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a URL pattern with wildcards into a RegExp.
|
||||
* Supports both pre-escaped patterns (e.g., 'https://api\\.example\\.com/*')
|
||||
* and unescaped patterns (e.g., 'https://api.example.com/*').
|
||||
*/
|
||||
private compilePattern(pattern: string): RegExp {
|
||||
// Check if pattern is already escaped (contains \. sequence)
|
||||
const isPreEscaped = pattern.includes('\\.');
|
||||
|
||||
let escaped: string;
|
||||
if (isPreEscaped) {
|
||||
// Pattern is already escaped, only convert * to .*
|
||||
escaped = pattern.replace(/\*/g, '.*');
|
||||
} else {
|
||||
// Escape special regex characters except *
|
||||
escaped = pattern
|
||||
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*');
|
||||
}
|
||||
return new RegExp(`^${escaped}$`, 'i');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is allowed by the whitelist
|
||||
* @param url - The URL to check
|
||||
* @returns True if the URL matches any allowed pattern
|
||||
*/
|
||||
isAllowed(url: string): boolean {
|
||||
// If no patterns configured, allow all (but still check for blocked)
|
||||
if (this.allowedPatterns.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.compiledPatterns.some((pattern) => pattern.test(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL should be blocked for security reasons (SSRF protection).
|
||||
* Uses ssrfGuard.ts for IP address blocking (authoritative implementation).
|
||||
* @param url - The URL to check
|
||||
* @returns True if the URL should be blocked
|
||||
*/
|
||||
isBlocked(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Check blocked hostnames (metadata endpoints, etc.)
|
||||
if (BLOCKED_HOSTS.includes(hostname)) {
|
||||
debugLogger.debug(`URL blocked: hostname ${hostname} is in blocklist`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if hostname is an IP address - use ssrfGuard for authoritative check
|
||||
if (this.isIpAddress(hostname)) {
|
||||
// Remove brackets from IPv6 addresses for isBlockedAddress
|
||||
const cleanHostname = hostname.replace(/^\[|\]$/g, '');
|
||||
if (isBlockedAddress(cleanHostname)) {
|
||||
debugLogger.debug(`URL blocked: IP ${hostname} is blocked`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
// Invalid URL, block it
|
||||
debugLogger.debug(`URL blocked: invalid URL format`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a URL for use in HTTP hooks
|
||||
* @param url - The URL to validate
|
||||
* @returns Validation result with allowed status and reason
|
||||
*/
|
||||
validate(url: string): { allowed: boolean; reason?: string } {
|
||||
// First check if blocked for security
|
||||
if (this.isBlocked(url)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'URL is blocked for security reasons (SSRF protection)',
|
||||
};
|
||||
}
|
||||
|
||||
// Then check whitelist
|
||||
if (!this.isAllowed(url)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `URL does not match any allowed pattern. Allowed patterns: ${this.allowedPatterns.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is an IP address (IPv4 or IPv6)
|
||||
* Uses Node.js net module for accurate validation of all IP formats
|
||||
* including ::1, ::ffff:192.168.1.1, 2001:db8::1, etc.
|
||||
*/
|
||||
private isIpAddress(hostname: string): boolean {
|
||||
// Remove brackets from IPv6 addresses (e.g., [::1] -> ::1)
|
||||
const cleanHostname = hostname.replace(/^\[|\]$/g, '');
|
||||
return isIPv4(cleanHostname) || isIPv6(cleanHostname);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a URL validator from configuration
|
||||
* @param allowedUrls - Array of allowed URL patterns from config
|
||||
* @returns Configured URL validator
|
||||
*/
|
||||
export function createUrlValidator(allowedUrls?: string[]): UrlValidator {
|
||||
return new UrlValidator(allowedUrls || []);
|
||||
}
|
||||
|
|
@ -282,7 +282,7 @@ export * from './test-utils/index.js';
|
|||
|
||||
export * from './hooks/types.js';
|
||||
export { HookSystem, HookRegistry } from './hooks/index.js';
|
||||
export type { HookRegistryEntry } from './hooks/index.js';
|
||||
export type { HookRegistryEntry, SessionHookEntry } from './hooks/index.js';
|
||||
export { type StopFailureErrorType } from './hooks/types.js';
|
||||
|
||||
// Export hook triggers for all hook events
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as yaml from 'yaml';
|
||||
import { SkillManager } from './skill-manager.js';
|
||||
import { type SkillConfig, SkillError } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
|
@ -20,6 +21,8 @@ vi.mock('os');
|
|||
// Mock yaml parser - use vi.hoisted for proper hoisting
|
||||
const mockParseYaml = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Only mock yaml-parser for non-hooks tests
|
||||
// For hooks tests, we'll use the real parser by unmocking selectively
|
||||
vi.mock('../utils/yaml-parser.js', () => ({
|
||||
parse: mockParseYaml,
|
||||
stringify: vi.fn(),
|
||||
|
|
@ -45,6 +48,10 @@ describe('SkillManager', () => {
|
|||
// Setup yaml parser mocks with sophisticated behavior
|
||||
mockParseYaml.mockImplementation((yamlString: string) => {
|
||||
// Handle different test cases based on YAML content
|
||||
if (yamlString.includes('hooks:')) {
|
||||
// For hooks tests, use real YAML parser
|
||||
return yaml.parse(yamlString);
|
||||
}
|
||||
if (yamlString.includes('allowedTools:')) {
|
||||
return {
|
||||
name: 'test-skill',
|
||||
|
|
@ -894,4 +901,146 @@ Symlinked skill content`);
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hooks parsing', () => {
|
||||
it('should parse hooks configuration from frontmatter', () => {
|
||||
const markdown = `---
|
||||
name: hook-skill
|
||||
description: Skill with hooks
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- matcher: "Bash"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'echo "checking"'
|
||||
timeout: 5
|
||||
---
|
||||
Skill content`;
|
||||
|
||||
const config = manager.parseSkillContent(
|
||||
markdown,
|
||||
'/test/skill/SKILL.md',
|
||||
'user',
|
||||
);
|
||||
|
||||
expect(config.hooks).toBeDefined();
|
||||
expect(config.hooks?.PreToolUse).toBeDefined();
|
||||
expect(config.hooks?.PreToolUse).toHaveLength(1);
|
||||
expect(config.hooks?.PreToolUse?.[0]?.matcher).toBe('Bash');
|
||||
expect(config.hooks?.PreToolUse?.[0]?.hooks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should parse multiple hooks for same event', () => {
|
||||
const markdown = `---
|
||||
name: multi-hook-skill
|
||||
description: Skill with multiple hooks
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- matcher: "Bash"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'echo "first"'
|
||||
- type: command
|
||||
command: 'echo "second"'
|
||||
- matcher: "Write"
|
||||
hooks:
|
||||
- type: http
|
||||
url: 'https://example.com/hook'
|
||||
---
|
||||
Skill content`;
|
||||
|
||||
const config = manager.parseSkillContent(
|
||||
markdown,
|
||||
'/test/skill/SKILL.md',
|
||||
'user',
|
||||
);
|
||||
|
||||
expect(config.hooks?.PreToolUse).toHaveLength(2);
|
||||
expect(config.hooks?.PreToolUse?.[0]?.hooks).toHaveLength(2);
|
||||
expect(config.hooks?.PreToolUse?.[1]?.matcher).toBe('Write');
|
||||
});
|
||||
|
||||
it('should parse HTTP hooks with headers', () => {
|
||||
const markdown = `---
|
||||
name: http-hook-skill
|
||||
description: Skill with HTTP hooks
|
||||
hooks:
|
||||
PostToolUse:
|
||||
- matcher: "*"
|
||||
hooks:
|
||||
- type: http
|
||||
url: 'https://audit.example.com/log'
|
||||
headers:
|
||||
Authorization: 'Bearer token'
|
||||
allowedEnvVars:
|
||||
- API_KEY
|
||||
timeout: 10
|
||||
---
|
||||
Skill content`;
|
||||
|
||||
const config = manager.parseSkillContent(
|
||||
markdown,
|
||||
'/test/skill/SKILL.md',
|
||||
'user',
|
||||
);
|
||||
|
||||
expect(config.hooks?.PostToolUse).toHaveLength(1);
|
||||
const hook = config.hooks?.PostToolUse?.[0]?.hooks?.[0];
|
||||
expect(hook?.type).toBe('http');
|
||||
if (hook?.type === 'http') {
|
||||
expect(hook.url).toBe('https://audit.example.com/log');
|
||||
expect(hook.headers).toEqual({ Authorization: 'Bearer token' });
|
||||
expect(hook.allowedEnvVars).toEqual(['API_KEY']);
|
||||
expect(hook.timeout).toBe(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('should ignore unknown hook events', () => {
|
||||
const markdown = `---
|
||||
name: unknown-event-skill
|
||||
description: Skill with unknown event
|
||||
hooks:
|
||||
UnknownEvent:
|
||||
- matcher: "*"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'echo "test"'
|
||||
---
|
||||
Skill content`;
|
||||
|
||||
const config = manager.parseSkillContent(
|
||||
markdown,
|
||||
'/test/skill/SKILL.md',
|
||||
'user',
|
||||
);
|
||||
|
||||
// Unknown events are ignored, only valid HookEventNames are kept
|
||||
expect(config.hooks).toBeDefined();
|
||||
// UnknownEvent should not be in the parsed hooks
|
||||
expect(Object.keys(config.hooks || {})).not.toContain('UnknownEvent');
|
||||
});
|
||||
|
||||
it('should set skillRoot from filePath', () => {
|
||||
const markdown = `---
|
||||
name: skillroot-skill
|
||||
description: Skill with skillRoot
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- matcher: "Bash"
|
||||
hooks:
|
||||
- type: command
|
||||
command: 'echo $QWEN_SKILL_ROOT'
|
||||
---
|
||||
Skill content`;
|
||||
|
||||
const config = manager.parseSkillContent(
|
||||
markdown,
|
||||
'/test/skill/SKILL.md',
|
||||
'user',
|
||||
);
|
||||
|
||||
// skillRoot should be set to the directory containing SKILL.md
|
||||
expect(config.skillRoot).toBe('/test/skill');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import * as os from 'os';
|
|||
import { fileURLToPath } from 'url';
|
||||
import { watch as watchFs, type FSWatcher } from 'chokidar';
|
||||
import { parse as parseYaml } from '../utils/yaml-parser.js';
|
||||
import * as yaml from 'yaml';
|
||||
import type {
|
||||
SkillConfig,
|
||||
SkillLevel,
|
||||
ListSkillsOptions,
|
||||
SkillValidationResult,
|
||||
SkillHooksSettings,
|
||||
} from './types.js';
|
||||
import { SkillError, SkillErrorCode, parseModelField } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
|
@ -23,6 +25,13 @@ import { validateConfig } from './skill-load.js';
|
|||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { normalizeContent } from '../utils/textUtils.js';
|
||||
import { SKILL_PROVIDER_CONFIG_DIRS } from '../config/storage.js';
|
||||
import {
|
||||
HookEventName,
|
||||
HookType,
|
||||
type HookDefinition,
|
||||
type CommandHookConfig,
|
||||
type HttpHookConfig,
|
||||
} from '../hooks/types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SKILL_MANAGER');
|
||||
|
||||
|
|
@ -396,6 +405,25 @@ export class SkillManager {
|
|||
}
|
||||
}
|
||||
|
||||
// Extract hooks configuration
|
||||
// Use full YAML parser for hooks as they have nested structures
|
||||
let hooks: SkillHooksSettings | undefined;
|
||||
if (frontmatterYaml.includes('hooks:')) {
|
||||
// Re-parse with full YAML parser to get nested hooks structure
|
||||
const fullFrontmatter = yaml.parse(frontmatterYaml) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const hooksRaw = fullFrontmatter['hooks'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (hooksRaw !== undefined) {
|
||||
hooks = this.parseHooksConfig(hooksRaw);
|
||||
}
|
||||
}
|
||||
|
||||
// Set skillRoot to the directory containing SKILL.md
|
||||
const skillRoot = path.dirname(filePath);
|
||||
// Extract optional model field
|
||||
const model = parseModelField(frontmatter);
|
||||
|
||||
|
|
@ -403,6 +431,8 @@ export class SkillManager {
|
|||
name,
|
||||
description,
|
||||
allowedTools,
|
||||
hooks,
|
||||
skillRoot,
|
||||
model,
|
||||
level,
|
||||
filePath,
|
||||
|
|
@ -429,6 +459,116 @@ export class SkillManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses hooks configuration from frontmatter.
|
||||
*
|
||||
* @param hooksRaw - Raw hooks object from frontmatter
|
||||
* @returns Parsed SkillHooksSettings
|
||||
*/
|
||||
private parseHooksConfig(
|
||||
hooksRaw: Record<string, unknown>,
|
||||
): SkillHooksSettings {
|
||||
const hooks: SkillHooksSettings = {};
|
||||
|
||||
// Get valid hook event names
|
||||
const validEvents = Object.values(HookEventName);
|
||||
|
||||
for (const [eventName, matchersRaw] of Object.entries(hooksRaw)) {
|
||||
// Validate event name
|
||||
if (!validEvents.includes(eventName as HookEventName)) {
|
||||
debugLogger.warn(`Unknown hook event: ${eventName}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse matchers array
|
||||
if (!Array.isArray(matchersRaw)) {
|
||||
debugLogger.warn(`Hooks for ${eventName} must be an array, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchers: HookDefinition[] = [];
|
||||
for (const matcherRaw of matchersRaw) {
|
||||
if (typeof matcherRaw !== 'object' || matcherRaw === null) {
|
||||
debugLogger.warn(`Invalid matcher in ${eventName}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matcher = matcherRaw as Record<string, unknown>;
|
||||
const hookDef = this.parseHookMatcher(matcher);
|
||||
if (hookDef) {
|
||||
matchers.push(hookDef);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchers.length > 0) {
|
||||
hooks[eventName as HookEventName] = matchers;
|
||||
}
|
||||
}
|
||||
|
||||
return hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single hook matcher configuration.
|
||||
*
|
||||
* @param matcher - Raw matcher object
|
||||
* @returns HookDefinition or null if invalid
|
||||
*/
|
||||
private parseHookMatcher(
|
||||
matcher: Record<string, unknown>,
|
||||
): HookDefinition | null {
|
||||
const matcherPattern = matcher['matcher'] as string | undefined;
|
||||
const hooksRaw = matcher['hooks'] as unknown[] | undefined;
|
||||
|
||||
if (!hooksRaw || !Array.isArray(hooksRaw)) {
|
||||
debugLogger.warn('Matcher missing hooks array, skipping');
|
||||
return null;
|
||||
}
|
||||
|
||||
const hooks: Array<CommandHookConfig | HttpHookConfig> = [];
|
||||
|
||||
for (const hookRaw of hooksRaw) {
|
||||
if (typeof hookRaw !== 'object' || hookRaw === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hook = hookRaw as Record<string, unknown>;
|
||||
const hookType = hook['type'] as string;
|
||||
|
||||
if (hookType === 'command') {
|
||||
const commandHook: CommandHookConfig = {
|
||||
type: HookType.Command,
|
||||
command: hook['command'] as string,
|
||||
timeout: hook['timeout'] as number | undefined,
|
||||
statusMessage: hook['statusMessage'] as string | undefined,
|
||||
shell: hook['shell'] as 'bash' | 'powershell' | undefined,
|
||||
};
|
||||
hooks.push(commandHook);
|
||||
} else if (hookType === 'http') {
|
||||
const httpHook: HttpHookConfig = {
|
||||
type: HookType.Http,
|
||||
url: hook['url'] as string,
|
||||
headers: hook['headers'] as Record<string, string> | undefined,
|
||||
allowedEnvVars: hook['allowedEnvVars'] as string[] | undefined,
|
||||
timeout: hook['timeout'] as number | undefined,
|
||||
statusMessage: hook['statusMessage'] as string | undefined,
|
||||
};
|
||||
hooks.push(httpHook);
|
||||
} else {
|
||||
debugLogger.warn(`Unknown hook type: ${hookType}, skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hooks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
matcher: matcherPattern,
|
||||
hooks,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the base directory for skills at a specific level.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { HookDefinition, HookEventName } from '../hooks/types.js';
|
||||
|
||||
/**
|
||||
* Represents the storage level for a skill configuration.
|
||||
* - 'project': Stored in `.qwen/skills/` within the project directory
|
||||
|
|
@ -13,6 +15,14 @@
|
|||
*/
|
||||
export type SkillLevel = 'project' | 'user' | 'extension' | 'bundled';
|
||||
|
||||
/**
|
||||
* Hooks configuration for a skill.
|
||||
* Maps hook event names to hook definitions.
|
||||
*/
|
||||
export type SkillHooksSettings = Partial<
|
||||
Record<HookEventName, HookDefinition[]>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Core configuration for a skill as stored in SKILL.md files.
|
||||
* Each skill directory contains a SKILL.md file with YAML frontmatter
|
||||
|
|
@ -31,6 +41,12 @@ export interface SkillConfig {
|
|||
*/
|
||||
allowedTools?: string[];
|
||||
|
||||
/**
|
||||
* Hooks to register when this skill is invoked.
|
||||
* Hooks are registered as session-scoped hooks that persist
|
||||
* for the duration of the session.
|
||||
*/
|
||||
hooks?: SkillHooksSettings;
|
||||
/**
|
||||
* Optional model override for this skill's execution.
|
||||
* Uses the same selector syntax as subagent model selectors:
|
||||
|
|
@ -49,6 +65,12 @@ export interface SkillConfig {
|
|||
*/
|
||||
filePath: string;
|
||||
|
||||
/**
|
||||
* Absolute path to the skill root directory (directory containing SKILL.md).
|
||||
* Used to set QWEN_SKILL_ROOT environment variable for skill hooks.
|
||||
*/
|
||||
skillRoot?: string;
|
||||
|
||||
/**
|
||||
* The markdown body content from SKILL.md (after the frontmatter)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -802,6 +802,9 @@ export class AuthEvent implements BaseTelemetryEvent {
|
|||
}
|
||||
}
|
||||
|
||||
/** Hook type for telemetry */
|
||||
export type HookTelemetryType = 'command' | 'http' | 'function';
|
||||
|
||||
/**
|
||||
* Hook call telemetry event
|
||||
*/
|
||||
|
|
@ -809,7 +812,7 @@ export class HookCallEvent implements BaseTelemetryEvent {
|
|||
'event.name': string;
|
||||
'event.timestamp': string;
|
||||
hook_event_name: string;
|
||||
hook_type: 'command';
|
||||
hook_type: HookTelemetryType;
|
||||
hook_name: string;
|
||||
hook_input: Record<string, unknown>;
|
||||
hook_output?: Record<string, unknown>;
|
||||
|
|
@ -822,7 +825,7 @@ export class HookCallEvent implements BaseTelemetryEvent {
|
|||
|
||||
constructor(
|
||||
hookEventName: string,
|
||||
hookType: 'command',
|
||||
hookType: HookTelemetryType,
|
||||
hookName: string,
|
||||
hookInput: Record<string, unknown>,
|
||||
durationMs: number,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type { SkillConfig } from '../skills/types.js';
|
|||
import { logSkillLaunch, SkillLaunchEvent } from '../telemetry/index.js';
|
||||
import path from 'path';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { registerSkillHooks } from '../hooks/registerSkillHooks.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SKILL');
|
||||
|
||||
|
|
@ -275,6 +276,42 @@ class SkillToolInvocation extends BaseToolInvocation<SkillParams, ToolResult> {
|
|||
);
|
||||
this.onSkillLoaded(this.params.skill);
|
||||
|
||||
// Register skill hooks if present
|
||||
debugLogger.debug('Skill hooks check:', {
|
||||
hasHooks: !!skill.hooks,
|
||||
hooksKeys: skill.hooks ? Object.keys(skill.hooks) : [],
|
||||
skillName: skill.name,
|
||||
});
|
||||
if (skill.hooks) {
|
||||
const hookSystem = this.config.getHookSystem();
|
||||
const sessionId = this.config.getSessionId();
|
||||
debugLogger.debug('Hook system and session:', {
|
||||
hasHookSystem: !!hookSystem,
|
||||
sessionId,
|
||||
});
|
||||
if (hookSystem && sessionId) {
|
||||
const sessionHooksManager = hookSystem.getSessionHooksManager();
|
||||
const hookCount = registerSkillHooks(
|
||||
sessionHooksManager,
|
||||
sessionId,
|
||||
skill,
|
||||
);
|
||||
if (hookCount > 0) {
|
||||
debugLogger.info(
|
||||
`Registered ${hookCount} hooks from skill "${this.params.skill}"`,
|
||||
);
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`No hooks registered from skill "${this.params.skill}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`Skill "${this.params.skill}" has no hooks to register`,
|
||||
);
|
||||
}
|
||||
|
||||
const baseDir = path.dirname(skill.filePath);
|
||||
const llmContent = buildSkillLlmContent(baseDir, skill.body);
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue