mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🪝 feat: Add Claude Hook Compatibility Layer (#14412)
* feat: add Claude hook compatibility layer * fix: satisfy API declaration build * fix: normalize hook matchers and conditions * fix: align Claude hook lifecycle semantics * fix: preserve Claude hook execution semantics * fix: gate unsupported Claude hook controls * fix: refine Claude payload and matcher translation * fix: scope Claude hook conditions to tool events * fix: preserve mixed Claude hook documents * fix: honor Claude hook execution controls * fix: close Claude hook lifecycle gaps * fix: isolate Claude hook declaration state * fix: preserve Claude matcher semantics * fix: handle PreemptBoundary in plugin hook payload switch `PreemptBoundary` joined `HOOK_EVENTS` in @librechat/agents 3.4.2. It has no Claude counterpart and is absent from the compatibility EVENT_MAP, so it can never reach a registered declaration, but the payload switch must stay exhaustive so a future event fails the build rather than returning undefined.
This commit is contained in:
parent
39f5f9d846
commit
493ffced46
8 changed files with 3138 additions and 0 deletions
649
packages/api/src/agents/hooks/compatibility.spec.ts
Normal file
649
packages/api/src/agents/hooks/compatibility.spec.ts
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
import type { PluginHookCapabilities } from './compatibility';
|
||||
import type { PluginHooksDocument } from './schema';
|
||||
import { planPluginHooks } from './compatibility';
|
||||
|
||||
const commandCapabilities: PluginHookCapabilities = {
|
||||
handlerTypes: new Set(['command']),
|
||||
translateMatcher: ({ matcher }: { matcher: string }) => matcher,
|
||||
};
|
||||
|
||||
function document(hooks: PluginHooksDocument['hooks']): PluginHooksDocument {
|
||||
return { hooks };
|
||||
}
|
||||
|
||||
describe('planPluginHooks', () => {
|
||||
test('maps the common Claude lifecycle events directly', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
PreToolUse: [{ matcher: '^write_file$', hooks: [{ type: 'command', command: 'check' }] }],
|
||||
PostToolUse: [{ hooks: [{ type: 'command', command: 'record' }] }],
|
||||
Stop: [{ matcher: '.*', hooks: [{ type: 'command', command: 'verify' }] }],
|
||||
}),
|
||||
commandCapabilities,
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 3, ready: 3, unsupported: 0 });
|
||||
expect(
|
||||
plan.entries.map(({ sourceEvent, targetEvent, matcher }) => ({
|
||||
sourceEvent,
|
||||
targetEvent,
|
||||
matcher,
|
||||
})),
|
||||
).toEqual([
|
||||
{ sourceEvent: 'PreToolUse', targetEvent: 'PreToolUse', matcher: '^write_file$' },
|
||||
{ sourceEvent: 'PostToolUse', targetEvent: 'PostToolUse', matcher: undefined },
|
||||
{ sourceEvent: 'Stop', targetEvent: 'Stop', matcher: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test('maps SessionStart to RunStart with an explicit lifecycle warning', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
SessionStart: [
|
||||
{ matcher: 'resume', hooks: [{ type: 'command', command: 'load-context' }] },
|
||||
],
|
||||
}),
|
||||
{ handlerTypes: new Set(['command']), sessionLifecycle: true },
|
||||
);
|
||||
|
||||
expect(plan.summary.ready).toBe(1);
|
||||
expect(plan.entries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceEvent: 'SessionStart',
|
||||
targetEvent: 'RunStart',
|
||||
sourceMatcher: 'resume',
|
||||
matcher: '^(?:resume)$',
|
||||
status: 'ready',
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
code: 'event_alias',
|
||||
severity: 'warning',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes Claude exact and list matchers while preserving regex matchers', () => {
|
||||
const translatedMatchers: string[] = [];
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
PreToolUse: [
|
||||
{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'pipe-list' }] },
|
||||
{ matcher: 'Edit, Write', hooks: [{ type: 'command', command: 'comma-list' }] },
|
||||
{
|
||||
matcher: 'mcp__.*__write.*',
|
||||
hooks: [{ type: 'command', command: 'regex-pattern' }],
|
||||
},
|
||||
],
|
||||
SessionStart: [
|
||||
{ matcher: 'startup, resume', hooks: [{ type: 'command', command: 'load-context' }] },
|
||||
],
|
||||
SubagentStart: [
|
||||
{ matcher: 'code-reviewer', hooks: [{ type: 'command', command: 'track-agent' }] },
|
||||
],
|
||||
StopFailure: [
|
||||
{
|
||||
matcher: 'rate_limit, overloaded',
|
||||
hooks: [{ type: 'command', command: 'track-failure' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
...commandCapabilities,
|
||||
sessionLifecycle: true,
|
||||
translateMatcher: ({ matcher }) => {
|
||||
translatedMatchers.push(matcher);
|
||||
return matcher;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 6, ready: 6, unsupported: 0 });
|
||||
expect(plan.entries.map(({ sourceMatcher, matcher }) => ({ sourceMatcher, matcher }))).toEqual([
|
||||
{ sourceMatcher: 'Edit|Write', matcher: '^(?:Edit|Write)$' },
|
||||
{ sourceMatcher: 'Edit, Write', matcher: '^(?:Edit|Write)$' },
|
||||
{ sourceMatcher: 'mcp__.*__write.*', matcher: 'mcp__.*__write.*' },
|
||||
{ sourceMatcher: 'startup, resume', matcher: '^(?:startup|resume)$' },
|
||||
{ sourceMatcher: 'code-reviewer', matcher: '^(?:code-reviewer)$' },
|
||||
{ sourceMatcher: 'rate_limit, overloaded', matcher: 'rate_limit, overloaded' },
|
||||
]);
|
||||
expect(translatedMatchers).toEqual([
|
||||
'Edit|Write',
|
||||
'Edit|Write',
|
||||
'mcp__.*__write.*',
|
||||
'code-reviewer',
|
||||
'rate_limit, overloaded',
|
||||
]);
|
||||
});
|
||||
|
||||
test('fails closed for SessionStart compact matchers', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
SessionStart: [
|
||||
{ matcher: 'compact', hooks: [{ type: 'command', command: 'reload-context' }] },
|
||||
],
|
||||
}),
|
||||
{ handlerTypes: new Set(['command']), sessionLifecycle: true },
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 1, ready: 0, unsupported: 1 });
|
||||
expect(plan.entries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceEvent: 'SessionStart',
|
||||
targetEvent: 'RunStart',
|
||||
status: 'unsupported',
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'unsupported_session_source',
|
||||
severity: 'error',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps wildcard SessionStart ready while reporting compact as filtered', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: 'load-context' }] }],
|
||||
}),
|
||||
{ handlerTypes: new Set(['command']), sessionLifecycle: true },
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 1, ready: 1, unsupported: 0 });
|
||||
expect(plan.entries[0].issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'unsupported_session_source',
|
||||
severity: 'warning',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('plans translated compaction-trigger matchers', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
PreCompact: [{ matcher: 'auto', hooks: [{ type: 'command', command: 'before' }] }],
|
||||
PostCompact: [{ matcher: 'manual', hooks: [{ type: 'command', command: 'after' }] }],
|
||||
}),
|
||||
{
|
||||
handlerTypes: new Set(['command']),
|
||||
translateMatcher: ({ matcher }) =>
|
||||
matcher === 'auto'
|
||||
? '^(token_ratio|remaining_tokens|messages_to_refine|default)$'
|
||||
: '^manual$',
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 2, ready: 2, unsupported: 0 });
|
||||
expect(plan.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceEvent: 'PreCompact',
|
||||
sourceMatcher: 'auto',
|
||||
matcher: '^(?:^(token_ratio|remaining_tokens|messages_to_refine|default)$)$',
|
||||
status: 'ready',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
sourceEvent: 'PostCompact',
|
||||
sourceMatcher: 'manual',
|
||||
matcher: '^(?:^manual$)$',
|
||||
status: 'ready',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('plans StopFailure matchers against translated error names', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
StopFailure: [
|
||||
{
|
||||
matcher: 'rate_limit|overloaded',
|
||||
hooks: [{ type: 'command', command: 'record-failure' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
commandCapabilities,
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 1, ready: 1, unsupported: 0 });
|
||||
expect(plan.entries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceEvent: 'StopFailure',
|
||||
targetEvent: 'StopFailure',
|
||||
sourceMatcher: 'rate_limit|overloaded',
|
||||
matcher: '^(?:rate_limit|overloaded)$',
|
||||
status: 'ready',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not activate events whose Claude control surfaces are unavailable', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
SubagentStop: [{ hooks: [{ type: 'command', command: 'verify' }] }],
|
||||
PermissionDenied: [{ hooks: [{ type: 'command', command: 'retry' }] }],
|
||||
}),
|
||||
commandCapabilities,
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 2, ready: 0, unsupported: 2 });
|
||||
expect(plan.entries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceEvent: 'SubagentStop',
|
||||
targetEvent: 'SubagentStop',
|
||||
status: 'unsupported',
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
code: 'unsupported_event_payload',
|
||||
severity: 'error',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(plan.entries[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceEvent: 'PermissionDenied',
|
||||
targetEvent: 'PermissionDenied',
|
||||
status: 'unsupported',
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
code: 'unsupported_event_output',
|
||||
severity: 'error',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not silently activate events or handler types the runtime cannot execute', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
UserPromptExpansion: [{ hooks: [{ type: 'command', command: 'banner' }] }],
|
||||
Stop: [{ hooks: [{ type: 'prompt', prompt: 'Verify completion' }] }],
|
||||
}),
|
||||
commandCapabilities,
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 2, ready: 0, unsupported: 2 });
|
||||
expect(plan.entries[0].issues).toEqual([
|
||||
expect.objectContaining({ code: 'unsupported_event' }),
|
||||
]);
|
||||
expect(plan.entries[1].issues).toEqual([
|
||||
expect.objectContaining({ code: 'unsupported_handler' }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps known unsupported handler declarations in a mixed compatibility plan', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{ type: 'command', command: 'verify' },
|
||||
{ type: 'http', url: 'https://hooks.example.com/stop' },
|
||||
{ type: 'mcp_tool', server: 'policy', tool: 'validate', input: { strict: true } },
|
||||
{ type: 'agent', prompt: 'Review completion', model: 'claude-sonnet-4-5' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
commandCapabilities,
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 4, ready: 1, unsupported: 3 });
|
||||
expect(plan.entries[0].status).toBe('ready');
|
||||
expect(plan.entries.slice(1)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
handler: expect.objectContaining({ type: 'http' }),
|
||||
status: 'unsupported',
|
||||
issues: [expect.objectContaining({ code: 'unsupported_handler' })],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
handler: expect.objectContaining({ type: 'mcp_tool' }),
|
||||
status: 'unsupported',
|
||||
issues: [expect.objectContaining({ code: 'unsupported_handler' })],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
handler: expect.objectContaining({ type: 'agent' }),
|
||||
status: 'unsupported',
|
||||
issues: [expect.objectContaining({ code: 'unsupported_handler' })],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves continueOnBlock for supported prompt handlers', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
PostToolUse: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'prompt',
|
||||
prompt: 'Review this tool result',
|
||||
continueOnBlock: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
PreToolUse: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'prompt',
|
||||
prompt: 'Verify this tool call',
|
||||
continueOnBlock: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
...commandCapabilities,
|
||||
handlerTypes: new Set(['command', 'prompt']),
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 2, ready: 2, unsupported: 0 });
|
||||
expect(plan.entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
status: 'ready',
|
||||
handler: expect.objectContaining({ continueOnBlock: true }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects continueOnBlock on non-prompt handlers', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
PostToolUse: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: 'verify-output', continueOnBlock: true }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
commandCapabilities,
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 1, ready: 0, unsupported: 1 });
|
||||
expect(plan.entries[0].issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'unsupported_continue_on_block',
|
||||
severity: 'error',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects prompt handlers on events that do not support them', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
SessionStart: [
|
||||
{
|
||||
matcher: 'startup',
|
||||
hooks: [{ type: 'prompt', prompt: 'Load context' }],
|
||||
},
|
||||
],
|
||||
StopFailure: [{ hooks: [{ type: 'prompt', prompt: 'Classify the failure' }] }],
|
||||
}),
|
||||
{
|
||||
handlerTypes: new Set(['command', 'prompt']),
|
||||
sessionLifecycle: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 2, ready: 0, unsupported: 2 });
|
||||
for (const entry of plan.entries) {
|
||||
expect(entry.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'unsupported_handler_event',
|
||||
severity: 'error',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects conditional, async-rewake, and unsafe matcher semantics by default', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
RunStart: [
|
||||
{
|
||||
matcher: 'startup',
|
||||
hooks: [{ type: 'command', command: 'load' }],
|
||||
},
|
||||
],
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: '(a+)+',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'review',
|
||||
if: 'Bash(git commit:*)',
|
||||
async: true,
|
||||
asyncRewake: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
commandCapabilities,
|
||||
);
|
||||
|
||||
expect(plan.summary.unsupported).toBe(2);
|
||||
expect(plan.entries[0].issues).toEqual([
|
||||
expect.objectContaining({ code: 'unsupported_matcher' }),
|
||||
]);
|
||||
expect(plan.entries[1].issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'invalid_matcher' }),
|
||||
expect.objectContaining({ code: 'unsupported_condition' }),
|
||||
expect.objectContaining({ code: 'unsupported_async' }),
|
||||
expect.objectContaining({ code: 'unsupported_async_rewake' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('requires explicit matcher and session-lifecycle adapters', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'check' }] }],
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: 'load' }] }],
|
||||
}),
|
||||
{ handlerTypes: new Set(['command']) },
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 2, ready: 0, unsupported: 2 });
|
||||
expect(plan.entries[0].issues).toEqual([expect.objectContaining({ code: 'unmapped_matcher' })]);
|
||||
expect(plan.entries[1].issues).toEqual([
|
||||
expect.objectContaining({ code: 'unsupported_session_lifecycle' }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('records matcher translation without losing the source declaration', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
PreToolUse: [{ matcher: ' Bash|Write ', hooks: [{ type: 'command', command: 'check' }] }],
|
||||
}),
|
||||
{
|
||||
handlerTypes: new Set(['command']),
|
||||
translateMatcher: () => '^(bash_tool|create_file)$',
|
||||
toPluginToolName: ({ toolName }) => toolName,
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.entries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceMatcher: 'Bash|Write',
|
||||
matcher: '^(?:^(bash_tool|create_file)$)$',
|
||||
status: 'ready',
|
||||
issues: [expect.objectContaining({ code: 'matcher_translated', severity: 'warning' })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps invalid authored regex matchers unsupported after a valid translation', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
PreToolUse: [{ matcher: '[invalid', hooks: [{ type: 'command', command: 'check' }] }],
|
||||
}),
|
||||
{
|
||||
handlerTypes: new Set(['command']),
|
||||
translateMatcher: () => '^bash_tool$',
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 1, ready: 0, unsupported: 1 });
|
||||
expect(plan.entries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceMatcher: '[invalid',
|
||||
matcher: '^bash_tool$',
|
||||
status: 'unsupported',
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'invalid_matcher', severity: 'error' }),
|
||||
expect.objectContaining({ code: 'matcher_translated', severity: 'warning' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('requires reverse tool-name translation when matcher namespaces differ', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'check' }] }],
|
||||
}),
|
||||
{
|
||||
handlerTypes: new Set(['command']),
|
||||
translateMatcher: () => ({
|
||||
matcher: '^bash_tool$',
|
||||
requiresToolNameTranslation: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 1, ready: 0, unsupported: 1 });
|
||||
expect(plan.entries[0].issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'matcher_translated', severity: 'warning' }),
|
||||
expect.objectContaining({ code: 'unmapped_tool_name', severity: 'error' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('allows regex-only matcher rewrites without reverse tool-name mapping', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'check' }] }],
|
||||
}),
|
||||
{
|
||||
handlerTypes: new Set(['command']),
|
||||
translateMatcher: () => '^Bash$',
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 1, ready: 1, unsupported: 0 });
|
||||
expect(plan.entries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
matcher: '^(?:^Bash$)$',
|
||||
status: 'ready',
|
||||
issues: [expect.objectContaining({ code: 'matcher_translated', severity: 'warning' })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('plans current handler-level conditions independently', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'review-commit',
|
||||
if: 'Bash(git commit:*)',
|
||||
},
|
||||
{
|
||||
type: 'command',
|
||||
command: 'record-all',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
handlerTypes: new Set(['command']),
|
||||
translateMatcher: () => '^bash_tool$',
|
||||
toPluginToolName: ({ toolName }) => toolName,
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 2, ready: 1, unsupported: 1 });
|
||||
expect(plan.entries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
condition: 'Bash(git commit:*)',
|
||||
status: 'unsupported',
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'unsupported_condition' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(plan.entries[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'ready',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects conditional expressions on non-tool events', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'verify',
|
||||
if: 'Bash(git status:*)',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
handlerTypes: new Set(['command']),
|
||||
matchCondition: () => true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.summary).toEqual({ declared: 1, ready: 0, unsupported: 1 });
|
||||
expect(plan.entries[0].issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'unsupported_condition',
|
||||
severity: 'error',
|
||||
message: 'Conditional `if` hook expressions are only supported for tool events',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('converts portable timeout seconds to the SDK timeout in milliseconds', () => {
|
||||
const plan = planPluginHooks(
|
||||
document({
|
||||
Stop: [{ hooks: [{ type: 'command', command: 'verify', timeout: 30 }] }],
|
||||
}),
|
||||
commandCapabilities,
|
||||
);
|
||||
|
||||
expect(plan.entries[0].timeoutMs).toBe(30_000);
|
||||
});
|
||||
});
|
||||
544
packages/api/src/agents/hooks/compatibility.ts
Normal file
544
packages/api/src/agents/hooks/compatibility.ts
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
import { MAX_PATTERN_LENGTH, hasNestedQuantifier } from '@librechat/agents';
|
||||
import type { HookEvent } from '@librechat/agents';
|
||||
import type { PluginHookHandler, PluginHooksDocument } from './schema';
|
||||
|
||||
const EVENT_MAP = new Map<string, HookEvent>([
|
||||
['RunStart', 'RunStart'],
|
||||
['SessionStart', 'RunStart'],
|
||||
['UserPromptSubmit', 'UserPromptSubmit'],
|
||||
['PreToolUse', 'PreToolUse'],
|
||||
['PostToolUse', 'PostToolUse'],
|
||||
['PostToolUseFailure', 'PostToolUseFailure'],
|
||||
['PostToolBatch', 'PostToolBatch'],
|
||||
['PermissionDenied', 'PermissionDenied'],
|
||||
['SubagentStart', 'SubagentStart'],
|
||||
['SubagentStop', 'SubagentStop'],
|
||||
['Stop', 'Stop'],
|
||||
['StopFailure', 'StopFailure'],
|
||||
['PreCompact', 'PreCompact'],
|
||||
['PostCompact', 'PostCompact'],
|
||||
]);
|
||||
|
||||
const QUERY_EVENTS = new Set<HookEvent>([
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PermissionDenied',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
'StopFailure',
|
||||
'PreCompact',
|
||||
'PostCompact',
|
||||
]);
|
||||
|
||||
const TOOL_NAME_EVENTS = new Set<HookEvent>([
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PermissionDenied',
|
||||
]);
|
||||
|
||||
const GENERAL_EXACT_MATCHER = /^[-A-Za-z0-9_,| ]+$/;
|
||||
const NARROW_EXACT_MATCHER = /^[A-Za-z0-9_|]+$/;
|
||||
const NARROW_EXACT_MATCHER_EVENTS = new Set(['FileChanged', 'StopFailure']);
|
||||
|
||||
export type PluginHookIssueSeverity = 'warning' | 'error';
|
||||
|
||||
export type PluginHookIssueCode =
|
||||
| 'event_alias'
|
||||
| 'unsupported_event'
|
||||
| 'unsupported_handler'
|
||||
| 'invalid_matcher'
|
||||
| 'matcher_translated'
|
||||
| 'unmapped_matcher'
|
||||
| 'unmapped_tool_name'
|
||||
| 'unsupported_matcher'
|
||||
| 'unsupported_condition'
|
||||
| 'conflicting_condition'
|
||||
| 'unsupported_async'
|
||||
| 'unsupported_async_rewake'
|
||||
| 'unsupported_continue_on_block'
|
||||
| 'unsupported_handler_event'
|
||||
| 'unsupported_session_lifecycle'
|
||||
| 'unsupported_session_source'
|
||||
| 'unsupported_event_payload'
|
||||
| 'unsupported_event_output'
|
||||
| 'long_timeout';
|
||||
|
||||
export interface PluginHookCompatibilityIssue {
|
||||
code: PluginHookIssueCode;
|
||||
severity: PluginHookIssueSeverity;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type PluginHookHandlerType = 'command' | 'prompt';
|
||||
|
||||
export interface PluginHookMatcherTranslation {
|
||||
sourceEvent: string;
|
||||
targetEvent: HookEvent;
|
||||
/** Authored regex, or a canonical pipe-separated list when Claude applies exact matching. */
|
||||
matcher: string;
|
||||
}
|
||||
|
||||
export interface PluginHookMatcherTranslationResult {
|
||||
/** Runtime pattern; translations of Claude exact matchers are whole-string anchored. */
|
||||
matcher: string;
|
||||
requiresToolNameTranslation?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginHookToolNameTranslation {
|
||||
sourceEvent: string;
|
||||
targetEvent: HookEvent;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
export interface PluginHookConditionMatch {
|
||||
sourceEvent: string;
|
||||
targetEvent: HookEvent;
|
||||
condition: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginHookCapabilities {
|
||||
handlerTypes: ReadonlySet<PluginHookHandlerType>;
|
||||
translateMatcher?: (
|
||||
input: PluginHookMatcherTranslation,
|
||||
) => string | PluginHookMatcherTranslationResult | undefined;
|
||||
/** Maps a LibreChat runtime tool name back into the plugin's source namespace. */
|
||||
toPluginToolName?: (input: PluginHookToolNameTranslation) => string;
|
||||
/** Evaluates Claude permission-rule syntax before a conditional handler executes. */
|
||||
matchCondition?: (input: PluginHookConditionMatch) => boolean;
|
||||
async?: boolean;
|
||||
asyncRewake?: boolean;
|
||||
sessionLifecycle?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginHookPlanEntry {
|
||||
sourceEvent: string;
|
||||
targetEvent?: HookEvent;
|
||||
groupIndex: number;
|
||||
handlerIndex: number;
|
||||
sourceMatcher?: string;
|
||||
matcher?: string;
|
||||
condition?: string;
|
||||
timeoutMs?: number;
|
||||
handler: PluginHookHandler;
|
||||
status: 'ready' | 'unsupported';
|
||||
issues: PluginHookCompatibilityIssue[];
|
||||
}
|
||||
|
||||
export interface PluginHookPlanSummary {
|
||||
declared: number;
|
||||
ready: number;
|
||||
unsupported: number;
|
||||
}
|
||||
|
||||
export interface PluginHookPlan {
|
||||
description?: string;
|
||||
entries: PluginHookPlanEntry[];
|
||||
summary: PluginHookPlanSummary;
|
||||
}
|
||||
|
||||
function normalizeMatcher(matcher: string | undefined): string | undefined {
|
||||
const trimmed = matcher?.trim();
|
||||
if (!trimmed || trimmed === '*' || trimmed === '.*') {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
type ClaudeMatcherSemantics =
|
||||
| { kind: 'exact'; canonicalMatcher: string; values: string[] }
|
||||
| { kind: 'regex'; canonicalMatcher: string };
|
||||
|
||||
function getClaudeMatcherSemantics(sourceEvent: string, matcher: string): ClaudeMatcherSemantics {
|
||||
const isNarrowEvent = NARROW_EXACT_MATCHER_EVENTS.has(sourceEvent);
|
||||
const exactMatcher = isNarrowEvent ? NARROW_EXACT_MATCHER : GENERAL_EXACT_MATCHER;
|
||||
if (!exactMatcher.test(matcher)) {
|
||||
return { kind: 'regex', canonicalMatcher: matcher };
|
||||
}
|
||||
const values = matcher.split(isNarrowEvent ? /\|/ : /[|,]/).map((value) => value.trim());
|
||||
return {
|
||||
kind: 'exact',
|
||||
canonicalMatcher: values.join('|'),
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
function anchorExactMatcher(matcher: string): string {
|
||||
return `^(?:${matcher})$`;
|
||||
}
|
||||
|
||||
function matcherIncludesValue(matcher: string, value: string): boolean {
|
||||
try {
|
||||
return new RegExp(matcher).test(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getMatcherValidationIssue(
|
||||
sourceEvent: string,
|
||||
matcher: string | undefined,
|
||||
targetEvent: HookEvent | undefined,
|
||||
): PluginHookCompatibilityIssue | undefined {
|
||||
if (!matcher) {
|
||||
return undefined;
|
||||
}
|
||||
if (targetEvent && !QUERY_EVENTS.has(targetEvent) && sourceEvent !== 'SessionStart') {
|
||||
return {
|
||||
code: 'unsupported_matcher',
|
||||
severity: 'error',
|
||||
message: `${targetEvent} does not expose a matcher query in the LibreChat hook runtime`,
|
||||
};
|
||||
}
|
||||
if (matcher.length > MAX_PATTERN_LENGTH || hasNestedQuantifier(matcher)) {
|
||||
return {
|
||||
code: 'invalid_matcher',
|
||||
severity: 'error',
|
||||
message: 'Matcher exceeds the safe regex limits enforced by the LibreChat hook runtime',
|
||||
};
|
||||
}
|
||||
try {
|
||||
void new RegExp(matcher);
|
||||
return undefined;
|
||||
} catch {
|
||||
return {
|
||||
code: 'invalid_matcher',
|
||||
severity: 'error',
|
||||
message: 'Matcher is not a valid regular expression',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getEventIssues(
|
||||
sourceEvent: string,
|
||||
targetEvent: HookEvent | undefined,
|
||||
capabilities: PluginHookCapabilities,
|
||||
): PluginHookCompatibilityIssue[] {
|
||||
if (!targetEvent) {
|
||||
return [
|
||||
{
|
||||
code: 'unsupported_event',
|
||||
severity: 'error',
|
||||
message: `${sourceEvent} has no equivalent LibreChat lifecycle event`,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sourceEvent === 'SubagentStop') {
|
||||
return [
|
||||
{
|
||||
code: 'unsupported_event_payload',
|
||||
severity: 'error',
|
||||
message:
|
||||
'SubagentStop is unavailable because the LibreChat hook input does not expose stop-hook state',
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sourceEvent === 'PermissionDenied') {
|
||||
return [
|
||||
{
|
||||
code: 'unsupported_event_output',
|
||||
severity: 'error',
|
||||
message:
|
||||
'PermissionDenied is unavailable because the LibreChat hook output cannot request a retry',
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sourceEvent !== 'SessionStart') {
|
||||
return [];
|
||||
}
|
||||
if (capabilities.sessionLifecycle !== true) {
|
||||
return [
|
||||
{
|
||||
code: 'unsupported_session_lifecycle',
|
||||
severity: 'error',
|
||||
message: 'SessionStart requires a runtime that deduplicates RunStart by plugin session',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
code: 'event_alias',
|
||||
severity: 'warning',
|
||||
message: 'SessionStart maps to RunStart with runtime-provided once-per-session semantics',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const PROMPT_UNSUPPORTED_EVENTS = new Set([
|
||||
'SessionStart',
|
||||
'PermissionDenied',
|
||||
'SubagentStart',
|
||||
'StopFailure',
|
||||
'PreCompact',
|
||||
'PostCompact',
|
||||
]);
|
||||
|
||||
function getHandlerIssues(
|
||||
sourceEvent: string,
|
||||
handler: PluginHookHandler,
|
||||
capabilities: PluginHookCapabilities,
|
||||
): PluginHookCompatibilityIssue[] {
|
||||
const issues: PluginHookCompatibilityIssue[] = [];
|
||||
const supportedHandlerType =
|
||||
handler.type === 'command' || handler.type === 'prompt' ? handler.type : undefined;
|
||||
if (!supportedHandlerType || !capabilities.handlerTypes.has(supportedHandlerType)) {
|
||||
issues.push({
|
||||
code: 'unsupported_handler',
|
||||
severity: 'error',
|
||||
message: `The configured executor does not support ${handler.type} hook handlers`,
|
||||
});
|
||||
}
|
||||
if (handler.type === 'prompt' && PROMPT_UNSUPPORTED_EVENTS.has(sourceEvent)) {
|
||||
issues.push({
|
||||
code: 'unsupported_handler_event',
|
||||
severity: 'error',
|
||||
message: `${sourceEvent} does not support prompt hook handlers`,
|
||||
});
|
||||
}
|
||||
if (handler.async === true && capabilities.async !== true) {
|
||||
issues.push({
|
||||
code: 'unsupported_async',
|
||||
severity: 'error',
|
||||
message: 'The configured executor does not support asynchronous hook execution',
|
||||
});
|
||||
}
|
||||
if (handler.asyncRewake === true && capabilities.asyncRewake !== true) {
|
||||
issues.push({
|
||||
code: 'unsupported_async_rewake',
|
||||
severity: 'error',
|
||||
message: 'The configured executor does not support asyncRewake',
|
||||
});
|
||||
}
|
||||
if (handler.continueOnBlock === true && handler.type !== 'prompt') {
|
||||
issues.push({
|
||||
code: 'unsupported_continue_on_block',
|
||||
severity: 'error',
|
||||
message: 'continueOnBlock is only supported for prompt hook handlers',
|
||||
});
|
||||
}
|
||||
if ((handler.timeout ?? 0) > 600) {
|
||||
issues.push({
|
||||
code: 'long_timeout',
|
||||
severity: 'warning',
|
||||
message: 'Hook timeout exceeds the portable 600-second compatibility target',
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
interface MatcherPlan {
|
||||
sourceMatcher?: string;
|
||||
matcher?: string;
|
||||
issues: PluginHookCompatibilityIssue[];
|
||||
}
|
||||
|
||||
function planMatcher(
|
||||
sourceEvent: string,
|
||||
targetEvent: HookEvent | undefined,
|
||||
configuredMatcher: string | undefined,
|
||||
capabilities: PluginHookCapabilities,
|
||||
): MatcherPlan {
|
||||
const sourceMatcher = normalizeMatcher(configuredMatcher);
|
||||
if (!sourceMatcher) {
|
||||
if (sourceEvent === 'SessionStart' && capabilities.sessionLifecycle === true) {
|
||||
return {
|
||||
issues: [
|
||||
{
|
||||
code: 'unsupported_session_source',
|
||||
severity: 'warning',
|
||||
message:
|
||||
'Wildcard SessionStart compatibility covers startup, resume, and clear; compact is runtime-filtered',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { issues: [] };
|
||||
}
|
||||
const matcherSemantics = getClaudeMatcherSemantics(sourceEvent, sourceMatcher);
|
||||
const validationIssue = getMatcherValidationIssue(sourceEvent, sourceMatcher, targetEvent);
|
||||
if (validationIssue?.code === 'unsupported_matcher' || !targetEvent) {
|
||||
return {
|
||||
sourceMatcher,
|
||||
matcher: sourceMatcher,
|
||||
issues: validationIssue ? [validationIssue] : [],
|
||||
};
|
||||
}
|
||||
if (sourceEvent === 'SessionStart') {
|
||||
const matcher =
|
||||
matcherSemantics.kind === 'exact'
|
||||
? anchorExactMatcher(matcherSemantics.canonicalMatcher)
|
||||
: sourceMatcher;
|
||||
const runtimeValidationIssue =
|
||||
validationIssue ?? getMatcherValidationIssue(sourceEvent, matcher, targetEvent);
|
||||
const includesCompact =
|
||||
matcherSemantics.kind === 'exact'
|
||||
? matcherSemantics.values.includes('compact')
|
||||
: matcherIncludesValue(matcher, 'compact');
|
||||
if (!runtimeValidationIssue && includesCompact) {
|
||||
return {
|
||||
sourceMatcher,
|
||||
matcher,
|
||||
issues: [
|
||||
{
|
||||
code: 'unsupported_session_source',
|
||||
severity: 'error',
|
||||
message:
|
||||
'SessionStart source "compact" is unavailable because LibreChat PostCompact hook output cannot inject session context',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
sourceMatcher,
|
||||
matcher,
|
||||
issues: runtimeValidationIssue ? [runtimeValidationIssue] : [],
|
||||
};
|
||||
}
|
||||
if (!capabilities.translateMatcher) {
|
||||
return {
|
||||
sourceMatcher,
|
||||
issues: [
|
||||
{
|
||||
code: 'unmapped_matcher',
|
||||
severity: 'error',
|
||||
message: 'Plugin matcher namespaces require an explicit LibreChat query translation',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let translation: string | PluginHookMatcherTranslationResult | undefined;
|
||||
try {
|
||||
translation = capabilities.translateMatcher({
|
||||
sourceEvent,
|
||||
targetEvent,
|
||||
matcher: matcherSemantics.canonicalMatcher,
|
||||
});
|
||||
} catch {
|
||||
translation = undefined;
|
||||
}
|
||||
const translatedMatcher = typeof translation === 'string' ? translation : translation?.matcher;
|
||||
const requiresToolNameTranslation =
|
||||
typeof translation === 'object' && translation.requiresToolNameTranslation === true;
|
||||
if (!translatedMatcher?.trim()) {
|
||||
return {
|
||||
sourceMatcher,
|
||||
issues: [
|
||||
{
|
||||
code: 'unmapped_matcher',
|
||||
severity: 'error',
|
||||
message: 'The configured matcher translator could not map this plugin matcher',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedTranslation = translatedMatcher.trim();
|
||||
const matcher =
|
||||
matcherSemantics.kind === 'exact'
|
||||
? anchorExactMatcher(normalizedTranslation)
|
||||
: normalizedTranslation;
|
||||
const issues: PluginHookCompatibilityIssue[] = validationIssue ? [validationIssue] : [];
|
||||
const translatedValidationIssue = getMatcherValidationIssue(sourceEvent, matcher, targetEvent);
|
||||
if (translatedValidationIssue && !validationIssue) {
|
||||
issues.push(translatedValidationIssue);
|
||||
}
|
||||
if (normalizedTranslation !== matcherSemantics.canonicalMatcher) {
|
||||
issues.push({
|
||||
code: 'matcher_translated',
|
||||
severity: 'warning',
|
||||
message: `Plugin matcher "${sourceMatcher}" maps to LibreChat matcher "${matcher}"`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
requiresToolNameTranslation &&
|
||||
TOOL_NAME_EVENTS.has(targetEvent) &&
|
||||
!capabilities.toPluginToolName
|
||||
) {
|
||||
issues.push({
|
||||
code: 'unmapped_tool_name',
|
||||
severity: 'error',
|
||||
message: 'Translated tool matchers require a reverse tool-name mapping for plugin payloads',
|
||||
});
|
||||
}
|
||||
return { sourceMatcher, matcher, issues };
|
||||
}
|
||||
|
||||
function hasError(issues: readonly PluginHookCompatibilityIssue[]): boolean {
|
||||
return issues.some((issue) => issue.severity === 'error');
|
||||
}
|
||||
|
||||
export function planPluginHooks(
|
||||
document: PluginHooksDocument,
|
||||
capabilities: PluginHookCapabilities,
|
||||
): PluginHookPlan {
|
||||
const entries: PluginHookPlanEntry[] = [];
|
||||
const summary: PluginHookPlanSummary = { declared: 0, ready: 0, unsupported: 0 };
|
||||
|
||||
for (const [sourceEvent, groups] of Object.entries(document.hooks)) {
|
||||
const targetEvent = EVENT_MAP.get(sourceEvent);
|
||||
const eventIssues = getEventIssues(sourceEvent, targetEvent, capabilities);
|
||||
|
||||
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
||||
const group = groups[groupIndex];
|
||||
const matcherPlan = planMatcher(sourceEvent, targetEvent, group.matcher, capabilities);
|
||||
const groupIssues = [...eventIssues, ...matcherPlan.issues];
|
||||
|
||||
for (let handlerIndex = 0; handlerIndex < group.hooks.length; handlerIndex++) {
|
||||
const handler = group.hooks[handlerIndex];
|
||||
const issues = [...groupIssues, ...getHandlerIssues(sourceEvent, handler, capabilities)];
|
||||
const condition = handler.if ?? group.if;
|
||||
if (handler.if && group.if && handler.if !== group.if) {
|
||||
issues.push({
|
||||
code: 'conflicting_condition',
|
||||
severity: 'error',
|
||||
message: 'A hook cannot combine different group-level and handler-level conditions',
|
||||
});
|
||||
}
|
||||
if (condition) {
|
||||
if (!targetEvent || !TOOL_NAME_EVENTS.has(targetEvent)) {
|
||||
issues.push({
|
||||
code: 'unsupported_condition',
|
||||
severity: 'error',
|
||||
message: 'Conditional `if` hook expressions are only supported for tool events',
|
||||
});
|
||||
} else if (!capabilities.matchCondition) {
|
||||
issues.push({
|
||||
code: 'unsupported_condition',
|
||||
severity: 'error',
|
||||
message: 'The configured executor does not support conditional `if` hook expressions',
|
||||
});
|
||||
}
|
||||
}
|
||||
const status = hasError(issues) ? 'unsupported' : 'ready';
|
||||
|
||||
entries.push({
|
||||
sourceEvent,
|
||||
targetEvent,
|
||||
groupIndex,
|
||||
handlerIndex,
|
||||
...(matcherPlan.sourceMatcher !== undefined && {
|
||||
sourceMatcher: matcherPlan.sourceMatcher,
|
||||
}),
|
||||
...(matcherPlan.matcher !== undefined && { matcher: matcherPlan.matcher }),
|
||||
...(condition !== undefined && { condition }),
|
||||
handler,
|
||||
status,
|
||||
issues,
|
||||
...(handler.timeout !== undefined && { timeoutMs: handler.timeout * 1_000 }),
|
||||
});
|
||||
summary.declared++;
|
||||
summary[status === 'ready' ? 'ready' : 'unsupported']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...(document.description !== undefined && { description: document.description }),
|
||||
entries,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
3
packages/api/src/agents/hooks/index.ts
Normal file
3
packages/api/src/agents/hooks/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './schema';
|
||||
export * from './runtime';
|
||||
export * from './compatibility';
|
||||
968
packages/api/src/agents/hooks/runtime.spec.ts
Normal file
968
packages/api/src/agents/hooks/runtime.spec.ts
Normal file
|
|
@ -0,0 +1,968 @@
|
|||
import { HookRegistry, executeHooks } from '@librechat/agents';
|
||||
import { AIMessage } from '@librechat/agents/langchain/messages';
|
||||
import type { HookOutput } from '@librechat/agents';
|
||||
import type { PluginHookCapabilities } from './compatibility';
|
||||
import type { PluginHookExecutor } from './runtime';
|
||||
import type { PluginHooksDocument } from './schema';
|
||||
import { createPluginHookPayload, registerPluginHooks } from './runtime';
|
||||
|
||||
const commandCapabilities: PluginHookCapabilities = {
|
||||
handlerTypes: new Set(['command']),
|
||||
translateMatcher: ({ matcher }: { matcher: string }) => matcher,
|
||||
};
|
||||
|
||||
function document(hooks: PluginHooksDocument['hooks']): PluginHooksDocument {
|
||||
return { hooks };
|
||||
}
|
||||
|
||||
function executor(output: HookOutput = {}): PluginHookExecutor & {
|
||||
execute: jest.Mock<Promise<HookOutput>, Parameters<PluginHookExecutor['execute']>>;
|
||||
} {
|
||||
const execute = jest.fn<Promise<HookOutput>, Parameters<PluginHookExecutor['execute']>>(
|
||||
async (_request, _signal) => output,
|
||||
);
|
||||
return {
|
||||
capabilities: {
|
||||
...commandCapabilities,
|
||||
handlerTypes: new Set(commandCapabilities.handlerTypes),
|
||||
},
|
||||
execute,
|
||||
};
|
||||
}
|
||||
|
||||
describe('registerPluginHooks', () => {
|
||||
test('registers a supported command hook and passes a Claude-shaped payload', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor({
|
||||
decision: 'deny',
|
||||
reason: 'Protected path',
|
||||
additionalContext: 'Policy checked',
|
||||
});
|
||||
hookExecutor.capabilities.matchCondition = ({ toolInput }) =>
|
||||
typeof toolInput.path === 'string' && toolInput.path.startsWith('/workspace/');
|
||||
const registration = registerPluginHooks({
|
||||
pluginId: 'security-guidance',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
context: {
|
||||
sessionId: 'conversation-1',
|
||||
cwd: '/workspace',
|
||||
permissionMode: 'default',
|
||||
},
|
||||
document: document({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: '^write_file$',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'check-write',
|
||||
if: 'Write(/workspace/**)',
|
||||
timeout: 5,
|
||||
once: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(registration.registered).toBe(1);
|
||||
expect(registry.getMatchers('PreToolUse')[0].timeout).toBe(5_000);
|
||||
expect(registry.getMatchers('PreToolUse')[0].once).toBeUndefined();
|
||||
|
||||
const result = await executeHooks({
|
||||
registry,
|
||||
matchQuery: 'write_file',
|
||||
input: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
runId: 'run-1',
|
||||
threadId: 'thread-1',
|
||||
agentId: 'agent-1',
|
||||
toolName: 'write_file',
|
||||
toolInput: { path: '/workspace/file.ts' },
|
||||
toolUseId: 'tool-1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
decision: 'deny',
|
||||
reason: 'Protected path',
|
||||
additionalContexts: ['Policy checked'],
|
||||
}),
|
||||
);
|
||||
expect(hookExecutor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginId: 'security-guidance',
|
||||
sourceEvent: 'PreToolUse',
|
||||
targetEvent: 'PreToolUse',
|
||||
condition: 'Write(/workspace/**)',
|
||||
payload: expect.objectContaining({
|
||||
hook_event_name: 'PreToolUse',
|
||||
session_id: 'conversation-1',
|
||||
run_id: 'run-1',
|
||||
thread_id: 'thread-1',
|
||||
cwd: '/workspace',
|
||||
permission_mode: 'default',
|
||||
agent_id: 'agent-1',
|
||||
tool_name: 'write_file',
|
||||
tool_input: { path: '/workspace/file.ts' },
|
||||
tool_use_id: 'tool-1',
|
||||
}),
|
||||
}),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
test('registers Claude exact and list matchers without substring matches', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
const registration = registerPluginHooks({
|
||||
pluginId: 'exact-tool-matches',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
document: document({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [{ type: 'command', command: 'pipe-list-audit' }],
|
||||
},
|
||||
{
|
||||
matcher: 'Edit, Write',
|
||||
hooks: [{ type: 'command', command: 'comma-list-audit' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const run = async (runId: string, toolName: string): Promise<void> => {
|
||||
await executeHooks({
|
||||
registry,
|
||||
matchQuery: toolName,
|
||||
input: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
runId,
|
||||
toolName,
|
||||
toolInput: {},
|
||||
toolUseId: `tool-${runId}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await run('substring', 'MultiEdit');
|
||||
expect(hookExecutor.execute).not.toHaveBeenCalled();
|
||||
|
||||
await run('exact', 'Edit');
|
||||
|
||||
expect(registration.plan.entries.map(({ matcher }) => matcher)).toEqual([
|
||||
'^(?:Edit|Write)$',
|
||||
'^(?:Edit|Write)$',
|
||||
]);
|
||||
expect(hookExecutor.execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('applies Claude default timeouts when handlers omit them', () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
hookExecutor.capabilities.handlerTypes = new Set(['command', 'prompt']);
|
||||
registerPluginHooks({
|
||||
pluginId: 'timeout-defaults',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
document: document({
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{ type: 'command', command: 'long-running-check' },
|
||||
{ type: 'prompt', prompt: 'Verify completion' },
|
||||
],
|
||||
},
|
||||
],
|
||||
UserPromptSubmit: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: 'validate-prompt' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(registry.getMatchers('Stop').map(({ timeout }) => timeout)).toEqual([600_000, 30_000]);
|
||||
expect(registry.getMatchers('UserPromptSubmit')[0].timeout).toBe(30_000);
|
||||
});
|
||||
|
||||
test('maps LibreChat tool names back into the plugin payload namespace', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
hookExecutor.capabilities.translateMatcher = () => ({
|
||||
matcher: '^bash_tool$',
|
||||
requiresToolNameTranslation: true,
|
||||
});
|
||||
hookExecutor.capabilities.toPluginToolName = ({ toolName }) =>
|
||||
toolName === 'bash_tool' ? 'Bash' : toolName;
|
||||
registerPluginHooks({
|
||||
pluginId: 'tool-name-adapter',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
document: document({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [{ type: 'command', command: 'check-bash' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await executeHooks({
|
||||
registry,
|
||||
matchQuery: 'bash_tool',
|
||||
input: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
runId: 'run-tool-name',
|
||||
toolName: 'bash_tool',
|
||||
toolInput: { command: 'npm test' },
|
||||
toolUseId: 'tool-name-1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(hookExecutor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({ toolName: 'bash_tool' }),
|
||||
payload: expect.objectContaining({ tool_name: 'Bash' }),
|
||||
}),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not consume a conditional once hook before its condition matches', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
hookExecutor.capabilities.translateMatcher = () => ({
|
||||
matcher: '^bash_tool$',
|
||||
requiresToolNameTranslation: true,
|
||||
});
|
||||
hookExecutor.capabilities.toPluginToolName = ({ toolName }) =>
|
||||
toolName === 'bash_tool' ? 'Bash' : toolName;
|
||||
hookExecutor.capabilities.matchCondition = ({ toolName, toolInput }) =>
|
||||
toolName === 'Bash' &&
|
||||
typeof toolInput.command === 'string' &&
|
||||
toolInput.command.startsWith('git commit ');
|
||||
registerPluginHooks({
|
||||
pluginId: 'commit-review',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
document: document({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'review-commit',
|
||||
if: 'Bash(git commit:*)',
|
||||
once: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(registry.getMatchers('PreToolUse')[0].once).toBeUndefined();
|
||||
|
||||
const run = async (runId: string, command: string): Promise<void> => {
|
||||
await executeHooks({
|
||||
registry,
|
||||
matchQuery: 'bash_tool',
|
||||
input: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
runId,
|
||||
threadId: 'commit-session',
|
||||
toolName: 'bash_tool',
|
||||
toolInput: { command },
|
||||
toolUseId: `tool-${runId}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await run('test', 'npm test');
|
||||
await run('commit', 'git commit -m "test"');
|
||||
await run('second-commit', 'git commit -m "again"');
|
||||
|
||||
expect(hookExecutor.execute).toHaveBeenCalledTimes(1);
|
||||
expect(hookExecutor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
condition: 'Bash(git commit:*)',
|
||||
input: expect.objectContaining({ runId: 'commit' }),
|
||||
}),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
test('scopes once handlers to each plugin session', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
registerPluginHooks({
|
||||
pluginId: 'session-once',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
document: document({
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Write',
|
||||
hooks: [{ type: 'command', command: 'initialize-write-audit', once: true }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const run = async (runId: string, threadId: string): Promise<void> => {
|
||||
await executeHooks({
|
||||
registry,
|
||||
matchQuery: 'Write',
|
||||
input: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
runId,
|
||||
threadId,
|
||||
toolName: 'Write',
|
||||
toolInput: { file_path: '/workspace/file.ts' },
|
||||
toolUseId: `tool-${runId}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await run('session-a-first', 'session-a');
|
||||
await run('session-a-second', 'session-a');
|
||||
await run('session-b-first', 'session-b');
|
||||
|
||||
expect(registry.getMatchers('PreToolUse')[0].once).toBeUndefined();
|
||||
expect(hookExecutor.execute).toHaveBeenCalledTimes(2);
|
||||
expect(hookExecutor.execute.mock.calls.map(([request]) => request.input.threadId)).toEqual([
|
||||
'session-a',
|
||||
'session-b',
|
||||
]);
|
||||
});
|
||||
|
||||
test('shares once state across duplicate overlapping handlers', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
const duplicateHandler = {
|
||||
type: 'command',
|
||||
command: 'initialize-write-audit',
|
||||
once: true,
|
||||
};
|
||||
registerPluginHooks({
|
||||
pluginId: 'deduplicated-session-once',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
document: document({
|
||||
PreToolUse: [
|
||||
{ matcher: '^Write$', hooks: [duplicateHandler] },
|
||||
{ matcher: '^Wri.*$', hooks: [{ ...duplicateHandler }] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const run = async (runId: string): Promise<void> => {
|
||||
await executeHooks({
|
||||
registry,
|
||||
matchQuery: 'Write',
|
||||
input: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
runId,
|
||||
threadId: 'shared-once-session',
|
||||
toolName: 'Write',
|
||||
toolInput: { file_path: '/workspace/file.ts' },
|
||||
toolUseId: `tool-${runId}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await run('first');
|
||||
await run('second');
|
||||
|
||||
expect(hookExecutor.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('keeps once state separate across disjoint matcher declarations', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
const duplicateHandler = {
|
||||
type: 'command',
|
||||
command: 'initialize-tool-audit',
|
||||
once: true,
|
||||
};
|
||||
registerPluginHooks({
|
||||
pluginId: 'separate-session-once',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
document: document({
|
||||
PreToolUse: [
|
||||
{ matcher: '^Write$', hooks: [duplicateHandler] },
|
||||
{ matcher: '^Bash$', hooks: [{ ...duplicateHandler }] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const run = async (runId: string, toolName: string): Promise<void> => {
|
||||
await executeHooks({
|
||||
registry,
|
||||
matchQuery: toolName,
|
||||
input: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
runId,
|
||||
threadId: 'separate-once-session',
|
||||
toolName,
|
||||
toolInput: {},
|
||||
toolUseId: `tool-${runId}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await run('write-first', 'Write');
|
||||
await run('write-second', 'Write');
|
||||
await run('bash-first', 'Bash');
|
||||
await run('bash-second', 'Bash');
|
||||
|
||||
expect(hookExecutor.execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('passes prompt continueOnBlock through to the executor', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
hookExecutor.capabilities.handlerTypes = new Set(['command', 'prompt']);
|
||||
registerPluginHooks({
|
||||
pluginId: 'self-correcting-review',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
document: document({
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'prompt',
|
||||
prompt: 'Verify this write result',
|
||||
continueOnBlock: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await executeHooks({
|
||||
registry,
|
||||
matchQuery: 'Write',
|
||||
input: {
|
||||
hook_event_name: 'PostToolUse',
|
||||
runId: 'run-write',
|
||||
toolName: 'Write',
|
||||
toolInput: { file_path: '/workspace/file.ts' },
|
||||
toolOutput: 'updated',
|
||||
toolUseId: 'tool-write',
|
||||
},
|
||||
});
|
||||
|
||||
expect(hookExecutor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
handler: expect.objectContaining({ continueOnBlock: true }),
|
||||
}),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
test('deduplicates identical handlers across overlapping matcher groups', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
const duplicateHandler = { type: 'command', command: 'audit-write' };
|
||||
const registration = registerPluginHooks({
|
||||
pluginId: 'deduplicated-hooks',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
document: document({
|
||||
PreToolUse: [
|
||||
{ matcher: '^write_file$', hooks: [duplicateHandler] },
|
||||
{ matcher: '^write_.*$', hooks: [{ ...duplicateHandler }] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await executeHooks({
|
||||
registry,
|
||||
matchQuery: 'write_file',
|
||||
input: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
runId: 'run-deduplicate',
|
||||
toolName: 'write_file',
|
||||
toolInput: { path: '/workspace/file.ts' },
|
||||
toolUseId: 'tool-deduplicate',
|
||||
},
|
||||
});
|
||||
|
||||
expect(registration.registered).toBe(2);
|
||||
expect(hookExecutor.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('filters and deduplicates SessionStart while registering RunStart', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor({ additionalContext: 'Loaded project context' });
|
||||
hookExecutor.capabilities.sessionLifecycle = true;
|
||||
delete hookExecutor.capabilities.translateMatcher;
|
||||
registerPluginHooks({
|
||||
pluginId: 'learning-output-style',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
context: {
|
||||
sessionStartSource: 'resume',
|
||||
model: 'claude-sonnet-4-6',
|
||||
agentType: 'code-reviewer',
|
||||
},
|
||||
document: document({
|
||||
SessionStart: [
|
||||
{
|
||||
matcher: 'resume',
|
||||
hooks: [{ type: 'command', command: 'load-context' }],
|
||||
},
|
||||
{
|
||||
matcher: 'startup',
|
||||
hooks: [{ type: 'command', command: 'do-not-load' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const firstResult = await executeHooks({
|
||||
registry,
|
||||
input: {
|
||||
hook_event_name: 'RunStart',
|
||||
runId: 'run-2',
|
||||
threadId: 'conversation-2',
|
||||
messages: [],
|
||||
},
|
||||
});
|
||||
await executeHooks({
|
||||
registry,
|
||||
input: {
|
||||
hook_event_name: 'RunStart',
|
||||
runId: 'run-3',
|
||||
threadId: 'conversation-2',
|
||||
messages: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(firstResult.additionalContexts).toEqual(['Loaded project context']);
|
||||
expect(hookExecutor.execute).toHaveBeenCalledTimes(1);
|
||||
expect(hookExecutor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceEvent: 'SessionStart',
|
||||
targetEvent: 'RunStart',
|
||||
handler: expect.objectContaining({ command: 'load-context' }),
|
||||
payload: expect.objectContaining({
|
||||
hook_event_name: 'SessionStart',
|
||||
session_id: 'conversation-2',
|
||||
source: 'resume',
|
||||
model: 'claude-sonnet-4-6',
|
||||
agent_type: 'code-reviewer',
|
||||
}),
|
||||
}),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
test('runtime-filters compact from wildcard SessionStart hooks', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
hookExecutor.capabilities.sessionLifecycle = true;
|
||||
registerPluginHooks({
|
||||
pluginId: 'compact-context',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
context: { sessionStartSource: 'compact', model: 'claude-sonnet-4-6' },
|
||||
document: document({
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: 'load-context' }] }],
|
||||
}),
|
||||
});
|
||||
|
||||
await executeHooks({
|
||||
registry,
|
||||
input: {
|
||||
hook_event_name: 'RunStart',
|
||||
runId: 'run-compact-session',
|
||||
threadId: 'conversation-compact-session',
|
||||
messages: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(hookExecutor.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shares SessionStart deduplication across overlapping declarations', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
hookExecutor.capabilities.sessionLifecycle = true;
|
||||
delete hookExecutor.capabilities.translateMatcher;
|
||||
const duplicateHandler = { type: 'command', command: 'load-context' };
|
||||
registerPluginHooks({
|
||||
pluginId: 'deduplicated-session-start',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
context: { sessionStartSource: 'resume' },
|
||||
document: document({
|
||||
SessionStart: [
|
||||
{ matcher: 'startup|resume', hooks: [duplicateHandler] },
|
||||
{ matcher: 'resume', hooks: [{ ...duplicateHandler }] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const run = async (runId: string): Promise<void> => {
|
||||
await executeHooks({
|
||||
registry,
|
||||
input: {
|
||||
hook_event_name: 'RunStart',
|
||||
runId,
|
||||
threadId: 'shared-session-start',
|
||||
messages: [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await run('first');
|
||||
await run('second');
|
||||
|
||||
expect(hookExecutor.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('keeps SessionStart state separate across disjoint source declarations', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
hookExecutor.capabilities.sessionLifecycle = true;
|
||||
const context = { sessionStartSource: 'startup' };
|
||||
const duplicateHandler = { type: 'command', command: 'load-context' };
|
||||
registerPluginHooks({
|
||||
pluginId: 'separate-session-start',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
context,
|
||||
document: document({
|
||||
SessionStart: [
|
||||
{ matcher: 'startup', hooks: [duplicateHandler] },
|
||||
{ matcher: 'resume', hooks: [{ ...duplicateHandler }] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const run = async (runId: string): Promise<void> => {
|
||||
await executeHooks({
|
||||
registry,
|
||||
input: {
|
||||
hook_event_name: 'RunStart',
|
||||
runId,
|
||||
threadId: 'separate-session-start',
|
||||
messages: [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await run('startup');
|
||||
context.sessionStartSource = 'resume';
|
||||
await run('resume');
|
||||
|
||||
expect(hookExecutor.execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('translates compaction matchers and carries the trigger into PostCompact', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
hookExecutor.capabilities.translateMatcher = ({ matcher }) =>
|
||||
matcher === 'auto' ? '^(token_ratio|remaining_tokens|messages_to_refine|default)$' : matcher;
|
||||
const registration = registerPluginHooks({
|
||||
pluginId: 'compact-audit',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
document: document({
|
||||
PreCompact: [
|
||||
{
|
||||
matcher: 'auto',
|
||||
hooks: [{ type: 'command', command: 'before-compact' }],
|
||||
},
|
||||
],
|
||||
PostCompact: [
|
||||
{
|
||||
matcher: 'auto',
|
||||
hooks: [{ type: 'command', command: 'after-compact' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await executeHooks({
|
||||
registry,
|
||||
input: {
|
||||
hook_event_name: 'PreCompact',
|
||||
runId: 'run-compact',
|
||||
threadId: 'conversation-compact',
|
||||
trigger: 'token_ratio',
|
||||
messagesBeforeCount: 12,
|
||||
},
|
||||
});
|
||||
await executeHooks({
|
||||
registry,
|
||||
input: {
|
||||
hook_event_name: 'PostCompact',
|
||||
runId: 'run-compact',
|
||||
threadId: 'conversation-compact',
|
||||
summary: 'Compacted context',
|
||||
messagesAfterCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(registration.registered).toBe(2);
|
||||
expect(registry.getMatchers('PreCompact')).toHaveLength(2);
|
||||
expect(registry.getMatchers('PostCompact')).toHaveLength(2);
|
||||
expect(hookExecutor.execute).toHaveBeenCalledTimes(2);
|
||||
expect(hookExecutor.execute).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
sourceEvent: 'PreCompact',
|
||||
payload: expect.objectContaining({
|
||||
hook_event_name: 'PreCompact',
|
||||
trigger: 'auto',
|
||||
messages_before_count: 12,
|
||||
}),
|
||||
}),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(hookExecutor.execute).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
sourceEvent: 'PostCompact',
|
||||
payload: expect.objectContaining({
|
||||
hook_event_name: 'PostCompact',
|
||||
trigger: 'auto',
|
||||
compact_summary: 'Compacted context',
|
||||
messages_after_count: 0,
|
||||
}),
|
||||
}),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
|
||||
await executeHooks({
|
||||
registry,
|
||||
input: {
|
||||
hook_event_name: 'PostCompact',
|
||||
runId: 'run-compact',
|
||||
threadId: 'conversation-compact',
|
||||
summary: 'No matching trigger',
|
||||
messagesAfterCount: 0,
|
||||
},
|
||||
});
|
||||
expect(hookExecutor.execute).toHaveBeenCalledTimes(2);
|
||||
|
||||
registration.unregister();
|
||||
expect(registry.getMatchers('PreCompact')).toHaveLength(0);
|
||||
expect(registry.getMatchers('PostCompact')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('translates batch entries and preserves the PostToolUseFailure error field', () => {
|
||||
const batchPayload = createPluginHookPayload('PostToolBatch', {
|
||||
hook_event_name: 'PostToolBatch',
|
||||
runId: 'run-batch',
|
||||
entries: [
|
||||
{
|
||||
toolName: 'Read',
|
||||
toolInput: { file_path: '/workspace/file.ts' },
|
||||
toolUseId: 'tool-success',
|
||||
toolOutput: 'file contents',
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
toolName: 'Bash',
|
||||
toolInput: { command: 'exit 1' },
|
||||
toolUseId: 'tool-failure',
|
||||
error: 'Command failed',
|
||||
status: 'error',
|
||||
},
|
||||
],
|
||||
});
|
||||
const failurePayload = createPluginHookPayload('PostToolUseFailure', {
|
||||
hook_event_name: 'PostToolUseFailure',
|
||||
runId: 'run-failure',
|
||||
toolName: 'Bash',
|
||||
toolInput: { command: 'exit 1' },
|
||||
toolUseId: 'tool-failure',
|
||||
error: 'Command failed',
|
||||
});
|
||||
|
||||
expect(batchPayload.tool_calls).toEqual([
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/workspace/file.ts' },
|
||||
tool_use_id: 'tool-success',
|
||||
tool_response: 'file contents',
|
||||
},
|
||||
{
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'exit 1' },
|
||||
tool_use_id: 'tool-failure',
|
||||
tool_response: 'Command failed',
|
||||
},
|
||||
]);
|
||||
expect(batchPayload).not.toHaveProperty('entries');
|
||||
expect(failurePayload).toEqual(
|
||||
expect.objectContaining({
|
||||
hook_event_name: 'PostToolUseFailure',
|
||||
error: 'Command failed',
|
||||
}),
|
||||
);
|
||||
expect(failurePayload).not.toHaveProperty('tool_error');
|
||||
});
|
||||
|
||||
test('emits the documented SubagentStart identity fields', () => {
|
||||
const payload = createPluginHookPayload('SubagentStart', {
|
||||
hook_event_name: 'SubagentStart',
|
||||
runId: 'run-subagent',
|
||||
parentAgentId: 'agent-parent',
|
||||
agentId: 'agent-child',
|
||||
agentType: 'Explore',
|
||||
inputs: [],
|
||||
});
|
||||
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'agent-child',
|
||||
agent_type: 'Explore',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('includes StopFailure assistant text when the SDK provides it', () => {
|
||||
const payload = createPluginHookPayload('StopFailure', {
|
||||
hook_event_name: 'StopFailure',
|
||||
runId: 'run-stop-failure',
|
||||
error: 'Model response could not be parsed',
|
||||
lastAssistantMessage: new AIMessage('Partial assistant response'),
|
||||
});
|
||||
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
hook_event_name: 'StopFailure',
|
||||
error: 'Model response could not be parsed',
|
||||
last_assistant_message: 'Partial assistant response',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('filters StopFailure matchers against the runtime error', async () => {
|
||||
const registry = new HookRegistry();
|
||||
const hookExecutor = executor();
|
||||
const registration = registerPluginHooks({
|
||||
pluginId: 'failure-audit',
|
||||
registry,
|
||||
executor: hookExecutor,
|
||||
document: document({
|
||||
StopFailure: [
|
||||
{
|
||||
matcher: 'rate_limit|overloaded',
|
||||
hooks: [{ type: 'command', command: 'record-failure' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(registration.registered).toBe(1);
|
||||
expect(registry.getMatchers('StopFailure')[0].pattern).toBeUndefined();
|
||||
|
||||
await executeHooks({
|
||||
registry,
|
||||
input: {
|
||||
hook_event_name: 'StopFailure',
|
||||
runId: 'run-auth-failure',
|
||||
error: 'authentication_failed',
|
||||
},
|
||||
});
|
||||
await executeHooks({
|
||||
registry,
|
||||
input: {
|
||||
hook_event_name: 'StopFailure',
|
||||
runId: 'run-rate-limit',
|
||||
error: 'rate_limit',
|
||||
},
|
||||
});
|
||||
|
||||
expect(hookExecutor.execute).toHaveBeenCalledTimes(1);
|
||||
expect(hookExecutor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceEvent: 'StopFailure',
|
||||
targetEvent: 'StopFailure',
|
||||
payload: expect.objectContaining({
|
||||
hook_event_name: 'StopFailure',
|
||||
error: 'rate_limit',
|
||||
}),
|
||||
}),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
test('registers the event surface used by the official hookify plugin', () => {
|
||||
const registry = new HookRegistry();
|
||||
const registration = registerPluginHooks({
|
||||
pluginId: 'hookify',
|
||||
registry,
|
||||
executor: executor(),
|
||||
document: document({
|
||||
PreToolUse: [{ hooks: [{ type: 'command', command: 'pretooluse.py' }] }],
|
||||
PostToolUse: [{ hooks: [{ type: 'command', command: 'posttooluse.py' }] }],
|
||||
Stop: [{ hooks: [{ type: 'command', command: 'stop.py' }] }],
|
||||
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'userpromptsubmit.py' }] }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(registration.registered).toBe(4);
|
||||
expect(registration.plan.summary).toEqual({ declared: 4, ready: 4, unsupported: 0 });
|
||||
expect(registry.getMatchers('PreToolUse')).toHaveLength(1);
|
||||
expect(registry.getMatchers('PostToolUse')).toHaveLength(1);
|
||||
expect(registry.getMatchers('Stop')).toHaveLength(1);
|
||||
expect(registry.getMatchers('UserPromptSubmit')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('leaves unsupported declarations in the plan without registering them', () => {
|
||||
const registry = new HookRegistry();
|
||||
const registration = registerPluginHooks({
|
||||
pluginId: 'mixed-plugin',
|
||||
registry,
|
||||
executor: executor(),
|
||||
document: document({
|
||||
UserPromptExpansion: [{ hooks: [{ type: 'command', command: 'banner' }] }],
|
||||
Stop: [{ hooks: [{ type: 'prompt', prompt: 'Verify completion' }] }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(registration.registered).toBe(0);
|
||||
expect(registration.plan.summary.unsupported).toBe(2);
|
||||
expect(registry.getMatchers('Stop')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('unregisters only this plugin registration and is idempotent', () => {
|
||||
const registry = new HookRegistry();
|
||||
const existing = jest.fn(async () => ({}));
|
||||
registry.register('Stop', { hooks: [existing] });
|
||||
const registration = registerPluginHooks({
|
||||
pluginId: 'ralph-loop',
|
||||
registry,
|
||||
executor: executor(),
|
||||
document: document({
|
||||
Stop: [{ hooks: [{ type: 'command', command: 'stop-hook.sh' }] }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(registry.getMatchers('Stop')).toHaveLength(2);
|
||||
registration.unregister();
|
||||
registration.unregister();
|
||||
expect(registry.getMatchers('Stop')).toHaveLength(1);
|
||||
expect(registry.getMatchers('Stop')[0].hooks[0]).toBe(existing);
|
||||
});
|
||||
});
|
||||
473
packages/api/src/agents/hooks/runtime.ts
Normal file
473
packages/api/src/agents/hooks/runtime.ts
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
import { HookRegistry, matchesQuery } from '@librechat/agents';
|
||||
import type {
|
||||
HookInput,
|
||||
HookOutput,
|
||||
HookMatcher,
|
||||
HookCallback,
|
||||
HookEvent,
|
||||
PostToolBatchEntry,
|
||||
} from '@librechat/agents';
|
||||
import type { PluginHookCapabilities, PluginHookPlan } from './compatibility';
|
||||
import type { PluginHookHandler, PluginHooksDocument } from './schema';
|
||||
import { planPluginHooks } from './compatibility';
|
||||
|
||||
export interface PluginHookRuntimeContext {
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
transcriptPath?: string | null;
|
||||
permissionMode?: string;
|
||||
sessionStartSource?: string;
|
||||
model?: string;
|
||||
agentType?: string;
|
||||
}
|
||||
|
||||
export interface PluginHookBatchToolCall {
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
tool_use_id: string;
|
||||
tool_response?: unknown;
|
||||
}
|
||||
|
||||
export interface PluginHookPayload {
|
||||
hook_event_name: string;
|
||||
session_id: string;
|
||||
run_id: string;
|
||||
thread_id?: string;
|
||||
cwd?: string;
|
||||
transcript_path?: string | null;
|
||||
permission_mode?: string;
|
||||
source?: string;
|
||||
model?: string;
|
||||
agent_id?: string;
|
||||
executing_agent_id?: string;
|
||||
prompt?: string;
|
||||
tool_name?: string;
|
||||
tool_input?: Record<string, unknown>;
|
||||
tool_use_id?: string;
|
||||
tool_response?: unknown;
|
||||
error?: string;
|
||||
last_assistant_message?: string;
|
||||
reason?: string;
|
||||
agent_type?: string;
|
||||
stop_hook_active?: boolean;
|
||||
trigger?: string;
|
||||
compact_summary?: string;
|
||||
messages_before_count?: number;
|
||||
messages_after_count?: number;
|
||||
tool_calls?: PluginHookBatchToolCall[];
|
||||
}
|
||||
|
||||
export interface PluginHookExecutionRequest {
|
||||
pluginId: string;
|
||||
sourceEvent: string;
|
||||
targetEvent: HookEvent;
|
||||
handler: PluginHookHandler;
|
||||
condition?: string;
|
||||
input: HookInput;
|
||||
payload: PluginHookPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-owned boundary for executing a planned hook.
|
||||
* This compatibility layer never launches plugin code in the LibreChat API process.
|
||||
*/
|
||||
export interface PluginHookExecutor {
|
||||
capabilities: PluginHookCapabilities;
|
||||
execute(
|
||||
request: PluginHookExecutionRequest,
|
||||
signal: AbortSignal,
|
||||
): HookOutput | Promise<HookOutput>;
|
||||
}
|
||||
|
||||
export interface RegisterPluginHooksOptions {
|
||||
pluginId: string;
|
||||
registry: HookRegistry;
|
||||
document: PluginHooksDocument;
|
||||
executor: PluginHookExecutor;
|
||||
context?: PluginHookRuntimeContext;
|
||||
}
|
||||
|
||||
export interface PluginHookRegistration {
|
||||
plan: PluginHookPlan;
|
||||
registered: number;
|
||||
unregister: () => void;
|
||||
}
|
||||
|
||||
interface PluginHookPayloadState {
|
||||
compactTrigger?: string;
|
||||
toPluginToolName?: (toolName: string) => string;
|
||||
}
|
||||
|
||||
function getSessionId(input: HookInput, context: PluginHookRuntimeContext): string {
|
||||
return context.sessionId ?? input.threadId ?? input.runId;
|
||||
}
|
||||
|
||||
function toClaudeCompactTrigger(trigger: string | undefined): string | undefined {
|
||||
if (!trigger) {
|
||||
return undefined;
|
||||
}
|
||||
return trigger === 'manual' ? 'manual' : 'auto';
|
||||
}
|
||||
|
||||
function getPluginToolName(toolName: string, state: PluginHookPayloadState): string {
|
||||
if (!state.toPluginToolName) {
|
||||
return toolName;
|
||||
}
|
||||
const translated = state.toPluginToolName(toolName);
|
||||
if (!translated?.trim()) {
|
||||
throw new Error(`No plugin tool-name mapping is available for "${toolName}"`);
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
|
||||
function getMessageText(message: unknown): string | undefined {
|
||||
if (!message || typeof message !== 'object' || !('content' in message)) {
|
||||
return undefined;
|
||||
}
|
||||
const content = (message as { content?: unknown }).content;
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return undefined;
|
||||
}
|
||||
const text = content
|
||||
.flatMap((block) => {
|
||||
if (typeof block === 'string') {
|
||||
return [block];
|
||||
}
|
||||
if (block && typeof block === 'object' && 'text' in block && typeof block.text === 'string') {
|
||||
return [block.text];
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.join('\n');
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function toPluginBatchToolCall(
|
||||
entry: PostToolBatchEntry,
|
||||
state: PluginHookPayloadState,
|
||||
): PluginHookBatchToolCall {
|
||||
const toolResponse = entry.status === 'success' ? entry.toolOutput : entry.error;
|
||||
return {
|
||||
tool_name: getPluginToolName(entry.toolName, state),
|
||||
tool_input: entry.toolInput,
|
||||
tool_use_id: entry.toolUseId,
|
||||
...(toolResponse !== undefined && { tool_response: toolResponse }),
|
||||
};
|
||||
}
|
||||
|
||||
function basePayload(
|
||||
sourceEvent: string,
|
||||
input: HookInput,
|
||||
context: PluginHookRuntimeContext,
|
||||
): PluginHookPayload {
|
||||
const sessionId = getSessionId(input, context);
|
||||
return {
|
||||
hook_event_name: sourceEvent,
|
||||
session_id: sessionId,
|
||||
run_id: input.runId,
|
||||
...(input.threadId !== undefined && { thread_id: input.threadId }),
|
||||
...(context.cwd !== undefined && { cwd: context.cwd }),
|
||||
...(context.transcriptPath !== undefined && { transcript_path: context.transcriptPath }),
|
||||
...(context.permissionMode !== undefined && { permission_mode: context.permissionMode }),
|
||||
...(input.agentId !== undefined && { agent_id: input.agentId }),
|
||||
...(input.executingAgentId !== undefined && {
|
||||
executing_agent_id: input.executingAgentId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginHookPayload(
|
||||
sourceEvent: string,
|
||||
input: HookInput,
|
||||
context: PluginHookRuntimeContext = {},
|
||||
state: PluginHookPayloadState = {},
|
||||
): PluginHookPayload {
|
||||
const payload = basePayload(sourceEvent, input, context);
|
||||
|
||||
switch (input.hook_event_name) {
|
||||
case 'RunStart':
|
||||
return {
|
||||
...payload,
|
||||
...(sourceEvent === 'SessionStart' && {
|
||||
source: context.sessionStartSource ?? 'startup',
|
||||
...(context.model !== undefined && { model: context.model }),
|
||||
...(context.agentType !== undefined && { agent_type: context.agentType }),
|
||||
}),
|
||||
};
|
||||
case 'UserPromptSubmit':
|
||||
return { ...payload, prompt: input.prompt };
|
||||
case 'PreToolUse':
|
||||
return {
|
||||
...payload,
|
||||
tool_name: getPluginToolName(input.toolName, state),
|
||||
tool_input: input.toolInput,
|
||||
tool_use_id: input.toolUseId,
|
||||
};
|
||||
case 'PostToolUse':
|
||||
return {
|
||||
...payload,
|
||||
tool_name: getPluginToolName(input.toolName, state),
|
||||
tool_input: input.toolInput,
|
||||
tool_use_id: input.toolUseId,
|
||||
tool_response: input.toolOutput,
|
||||
};
|
||||
case 'PostToolUseFailure':
|
||||
return {
|
||||
...payload,
|
||||
tool_name: getPluginToolName(input.toolName, state),
|
||||
tool_input: input.toolInput,
|
||||
tool_use_id: input.toolUseId,
|
||||
error: input.error,
|
||||
};
|
||||
case 'PostToolBatch':
|
||||
return {
|
||||
...payload,
|
||||
tool_calls: input.entries.map((entry) => toPluginBatchToolCall(entry, state)),
|
||||
};
|
||||
/** LibreChat steering seal; no Claude counterpart, so it is never mapped to a declaration. */
|
||||
case 'PreemptBoundary':
|
||||
return payload;
|
||||
case 'PermissionDenied':
|
||||
return {
|
||||
...payload,
|
||||
tool_name: getPluginToolName(input.toolName, state),
|
||||
tool_input: input.toolInput,
|
||||
tool_use_id: input.toolUseId,
|
||||
reason: input.reason,
|
||||
};
|
||||
case 'SubagentStart':
|
||||
case 'SubagentStop':
|
||||
return { ...payload, agent_type: input.agentType };
|
||||
case 'Stop':
|
||||
return { ...payload, stop_hook_active: input.stopHookActive };
|
||||
case 'StopFailure': {
|
||||
const lastAssistantMessage = getMessageText(input.lastAssistantMessage);
|
||||
return {
|
||||
...payload,
|
||||
error: input.error,
|
||||
...(lastAssistantMessage !== undefined && {
|
||||
last_assistant_message: lastAssistantMessage,
|
||||
}),
|
||||
};
|
||||
}
|
||||
case 'PreCompact':
|
||||
return {
|
||||
...payload,
|
||||
trigger: toClaudeCompactTrigger(input.trigger),
|
||||
messages_before_count: input.messagesBeforeCount,
|
||||
};
|
||||
case 'PostCompact':
|
||||
return {
|
||||
...payload,
|
||||
...(state.compactTrigger !== undefined && {
|
||||
trigger: toClaudeCompactTrigger(state.compactTrigger),
|
||||
}),
|
||||
compact_summary: input.summary,
|
||||
messages_after_count: input.messagesAfterCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getHookTimeoutMs(
|
||||
sourceEvent: string,
|
||||
handler: PluginHookHandler,
|
||||
configuredTimeoutMs: number | undefined,
|
||||
): number | undefined {
|
||||
if (configuredTimeoutMs !== undefined) {
|
||||
return configuredTimeoutMs;
|
||||
}
|
||||
if (handler.type === 'command') {
|
||||
return sourceEvent === 'UserPromptSubmit' ? 30_000 : 600_000;
|
||||
}
|
||||
if (handler.type === 'prompt') {
|
||||
return 30_000;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getHandlerIdentity(
|
||||
sourceEvent: string,
|
||||
handler: PluginHookHandler,
|
||||
condition: string | undefined,
|
||||
): string {
|
||||
const handlerProperties = Object.entries(handler)
|
||||
.filter(([key]) => key !== 'if')
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
return JSON.stringify([sourceEvent, condition ?? null, handlerProperties]);
|
||||
}
|
||||
|
||||
export function registerPluginHooks(options: RegisterPluginHooksOptions): PluginHookRegistration {
|
||||
const { pluginId, registry, document, executor, context = {} } = options;
|
||||
const plan = planPluginHooks(document, executor.capabilities);
|
||||
const unregisters: Array<() => void> = [];
|
||||
const compactTriggers = new Map<string, string>();
|
||||
const executedHandlers = new WeakMap<HookInput, Set<string>>();
|
||||
const tracksCompactTriggers = plan.entries.some(
|
||||
(entry) => entry.status === 'ready' && entry.targetEvent === 'PostCompact',
|
||||
);
|
||||
let registered = 0;
|
||||
|
||||
if (tracksCompactTriggers) {
|
||||
const trackCompactTrigger: HookCallback<'PreCompact'> = (input) => {
|
||||
compactTriggers.set(getSessionId(input, context), input.trigger);
|
||||
return {};
|
||||
};
|
||||
unregisters.push(
|
||||
registry.register('PreCompact', {
|
||||
hooks: [trackCompactTrigger],
|
||||
internal: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const entry of plan.entries) {
|
||||
if (entry.status !== 'ready' || entry.targetEvent === undefined) {
|
||||
continue;
|
||||
}
|
||||
const targetEvent = entry.targetEvent;
|
||||
const handlerIdentity = getHandlerIdentity(entry.sourceEvent, entry.handler, entry.condition);
|
||||
const seenSessionIds = entry.sourceEvent === 'SessionStart' ? new Set<string>() : undefined;
|
||||
const firedSessionIds = entry.handler.once === true ? new Set<string>() : undefined;
|
||||
const toolNameTranslator = executor.capabilities.toPluginToolName;
|
||||
const toPluginToolName =
|
||||
toolNameTranslator === undefined
|
||||
? undefined
|
||||
: (toolName: string): string =>
|
||||
toolNameTranslator({
|
||||
sourceEvent: entry.sourceEvent,
|
||||
targetEvent,
|
||||
toolName,
|
||||
});
|
||||
const hook: HookCallback<HookEvent> = (input, signal) => {
|
||||
const sessionId = getSessionId(input, context);
|
||||
const compactTrigger = compactTriggers.get(sessionId);
|
||||
if (entry.sourceEvent === 'SessionStart') {
|
||||
const source = context.sessionStartSource ?? 'startup';
|
||||
if (
|
||||
source === 'compact' ||
|
||||
!matchesQuery(entry.matcher, source) ||
|
||||
seenSessionIds?.has(sessionId) === true
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
seenSessionIds?.add(sessionId);
|
||||
}
|
||||
if (
|
||||
targetEvent === 'PreCompact' &&
|
||||
input.hook_event_name === 'PreCompact' &&
|
||||
!matchesQuery(entry.matcher, input.trigger)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
if (
|
||||
targetEvent === 'PostCompact' &&
|
||||
input.hook_event_name === 'PostCompact' &&
|
||||
(compactTrigger === undefined || !matchesQuery(entry.matcher, compactTrigger))
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
if (
|
||||
targetEvent === 'StopFailure' &&
|
||||
input.hook_event_name === 'StopFailure' &&
|
||||
!matchesQuery(entry.matcher, input.error)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
if (entry.condition !== undefined) {
|
||||
const matchCondition = executor.capabilities.matchCondition;
|
||||
if (!matchCondition || !('toolName' in input) || !('toolInput' in input)) {
|
||||
return {};
|
||||
}
|
||||
let matchesCondition = false;
|
||||
try {
|
||||
matchesCondition = matchCondition({
|
||||
sourceEvent: entry.sourceEvent,
|
||||
targetEvent,
|
||||
condition: entry.condition,
|
||||
toolName: getPluginToolName(input.toolName, { toPluginToolName }),
|
||||
toolInput: input.toolInput,
|
||||
});
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!matchesCondition) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (firedSessionIds?.has(sessionId) === true) {
|
||||
return {};
|
||||
}
|
||||
const handlersForInput = executedHandlers.get(input);
|
||||
if (handlersForInput?.has(handlerIdentity) === true) {
|
||||
firedSessionIds?.add(sessionId);
|
||||
return {};
|
||||
}
|
||||
if (handlersForInput) {
|
||||
handlersForInput.add(handlerIdentity);
|
||||
} else {
|
||||
executedHandlers.set(input, new Set([handlerIdentity]));
|
||||
}
|
||||
firedSessionIds?.add(sessionId);
|
||||
return executor.execute(
|
||||
{
|
||||
pluginId,
|
||||
sourceEvent: entry.sourceEvent,
|
||||
targetEvent,
|
||||
handler: entry.handler,
|
||||
...(entry.condition !== undefined && { condition: entry.condition }),
|
||||
input,
|
||||
payload: createPluginHookPayload(entry.sourceEvent, input, context, {
|
||||
compactTrigger,
|
||||
toPluginToolName,
|
||||
}),
|
||||
},
|
||||
signal,
|
||||
);
|
||||
};
|
||||
const runtimeFiltered =
|
||||
entry.sourceEvent === 'SessionStart' ||
|
||||
targetEvent === 'PreCompact' ||
|
||||
targetEvent === 'PostCompact' ||
|
||||
targetEvent === 'StopFailure';
|
||||
const timeoutMs = getHookTimeoutMs(entry.sourceEvent, entry.handler, entry.timeoutMs);
|
||||
const matcher: HookMatcher<HookEvent> = {
|
||||
hooks: [hook],
|
||||
...(!runtimeFiltered && entry.matcher !== undefined && { pattern: entry.matcher }),
|
||||
...(timeoutMs !== undefined && { timeout: timeoutMs }),
|
||||
};
|
||||
unregisters.push(registry.register(targetEvent, matcher));
|
||||
registered++;
|
||||
}
|
||||
|
||||
if (tracksCompactTriggers) {
|
||||
const clearCompactTrigger: HookCallback<'PostCompact'> = (input) => {
|
||||
compactTriggers.delete(getSessionId(input, context));
|
||||
return {};
|
||||
};
|
||||
unregisters.push(
|
||||
registry.register('PostCompact', {
|
||||
hooks: [clearCompactTrigger],
|
||||
internal: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let active = true;
|
||||
return {
|
||||
plan,
|
||||
registered,
|
||||
unregister: () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
active = false;
|
||||
compactTriggers.clear();
|
||||
for (let index = unregisters.length - 1; index >= 0; index--) {
|
||||
unregisters[index]();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
301
packages/api/src/agents/hooks/schema.spec.ts
Normal file
301
packages/api/src/agents/hooks/schema.spec.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { MAX_PATTERN_LENGTH } from '@librechat/agents';
|
||||
import { parsePluginHooks } from './schema';
|
||||
|
||||
describe('parsePluginHooks', () => {
|
||||
test('parses the plugin hooks wrapper used by Claude and Codex', () => {
|
||||
const result = parsePluginHooks({
|
||||
description: 'Guard file writes',
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Write|Edit',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'python3 "${CLAUDE_PLUGIN_ROOT}/hooks/check.py"',
|
||||
timeout: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
expect(result.document.hooks.PreToolUse[0].hooks[0]).toEqual({
|
||||
type: 'command',
|
||||
command: 'python3 "${CLAUDE_PLUGIN_ROOT}/hooks/check.py"',
|
||||
timeout: 10,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a direct settings-style event map without the plugin wrapper', () => {
|
||||
const result = parsePluginHooks({
|
||||
PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: 'check' }] }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ path: 'hooks' })]),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects command handlers without a command', () => {
|
||||
const result = parsePluginHooks({
|
||||
hooks: {
|
||||
Stop: [{ hooks: [{ type: 'command' }] }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: 'hooks.Stop.0.hooks.0.command',
|
||||
message: 'Command hooks require a non-empty command',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects prompt handlers without a prompt', () => {
|
||||
const result = parsePluginHooks({
|
||||
hooks: {
|
||||
Stop: [{ hooks: [{ type: 'prompt' }] }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: 'hooks.Stop.0.hooks.0.prompt',
|
||||
message: 'Prompt hooks require a non-empty prompt',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('retains compatibility modifiers needed by the planner', () => {
|
||||
const result = parsePluginHooks({
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: ' Bash ',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'review',
|
||||
if: ' Bash(git commit:*) ',
|
||||
asyncRewake: true,
|
||||
rewakeMessage: 'Review these findings',
|
||||
rewakeSummary: 'Review complete',
|
||||
continueOnBlock: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
expect(result.document.hooks.PostToolUse[0]).toEqual({
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'review',
|
||||
if: 'Bash(git commit:*)',
|
||||
asyncRewake: true,
|
||||
rewakeMessage: 'Review these findings',
|
||||
rewakeSummary: 'Review complete',
|
||||
continueOnBlock: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('retains official command and prompt handler options', () => {
|
||||
const result = parsePluginHooks({
|
||||
hooks: {
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'verify',
|
||||
args: ['--mode', 'strict'],
|
||||
shell: 'bash',
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
type: 'prompt',
|
||||
prompt: 'Check whether the task is complete',
|
||||
model: 'claude-sonnet-4-5',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
expect(result.document.hooks.Stop[0].hooks).toEqual([
|
||||
{
|
||||
type: 'command',
|
||||
command: 'verify',
|
||||
args: ['--mode', 'strict'],
|
||||
shell: 'bash',
|
||||
once: true,
|
||||
},
|
||||
{
|
||||
type: 'prompt',
|
||||
prompt: 'Check whether the task is complete',
|
||||
model: 'claude-sonnet-4-5',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('retains schema metadata and known handlers for compatibility planning', () => {
|
||||
const result = parsePluginHooks({
|
||||
$schema: 'https://json.schemastore.org/claude-code-settings.json',
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'verify',
|
||||
},
|
||||
{
|
||||
type: 'http',
|
||||
url: 'https://hooks.example.com/pre-tool',
|
||||
headers: { Authorization: 'Bearer ${HOOK_TOKEN}' },
|
||||
allowedEnvVars: ['HOOK_TOKEN'],
|
||||
},
|
||||
{
|
||||
type: 'mcp_tool',
|
||||
server: 'policy',
|
||||
tool: 'validate',
|
||||
input: { strict: true },
|
||||
},
|
||||
{
|
||||
type: 'agent',
|
||||
prompt: 'Review this tool call',
|
||||
model: 'claude-sonnet-4-5',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
expect(result.document.$schema).toBe('https://json.schemastore.org/claude-code-settings.json');
|
||||
expect(result.document.hooks.PreToolUse[0].hooks).toEqual([
|
||||
{
|
||||
type: 'command',
|
||||
command: 'verify',
|
||||
},
|
||||
{
|
||||
type: 'http',
|
||||
url: 'https://hooks.example.com/pre-tool',
|
||||
headers: { Authorization: 'Bearer ${HOOK_TOKEN}' },
|
||||
allowedEnvVars: ['HOOK_TOKEN'],
|
||||
},
|
||||
{
|
||||
type: 'mcp_tool',
|
||||
server: 'policy',
|
||||
tool: 'validate',
|
||||
input: { strict: true },
|
||||
},
|
||||
{
|
||||
type: 'agent',
|
||||
prompt: 'Review this tool call',
|
||||
model: 'claude-sonnet-4-5',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects blank conditions and oversized matchers', () => {
|
||||
const blankCondition = parsePluginHooks({
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [{ type: 'command', command: 'review', if: ' ' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const oversizedMatcher = parsePluginHooks({
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'a'.repeat(MAX_PATTERN_LENGTH + 1),
|
||||
hooks: [{ type: 'command', command: 'review' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(blankCondition.success).toBe(false);
|
||||
expect(oversizedMatcher.success).toBe(false);
|
||||
if (blankCondition.success || oversizedMatcher.success) {
|
||||
return;
|
||||
}
|
||||
expect(blankCondition.issues).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ path: 'hooks.PostToolUse.0.hooks.0.if' })]),
|
||||
);
|
||||
expect(oversizedMatcher.issues).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ path: 'hooks.PostToolUse.0.matcher' })]),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects unrecognized behavior instead of silently stripping it', () => {
|
||||
const result = parsePluginHooks({
|
||||
hooks: {
|
||||
Stop: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: 'verify', futureMode: 'detached' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: 'hooks.Stop.0.hooks.0',
|
||||
message: expect.stringContaining('futureMode'),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
199
packages/api/src/agents/hooks/schema.ts
Normal file
199
packages/api/src/agents/hooks/schema.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { z } from 'zod';
|
||||
import { MAX_PATTERN_LENGTH } from '@librechat/agents';
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 2_000;
|
||||
const MAX_COMMAND_LENGTH = 32_768;
|
||||
const MAX_HANDLER_TYPE_LENGTH = 64;
|
||||
const MAX_STATUS_MESSAGE_LENGTH = 500;
|
||||
const MAX_EVENT_NAME_LENGTH = 80;
|
||||
const MAX_GROUPS_PER_EVENT = 128;
|
||||
const MAX_HANDLERS_PER_GROUP = 32;
|
||||
const MAX_TOTAL_HANDLERS = 512;
|
||||
const MAX_TIMEOUT_SECONDS = 3_600;
|
||||
const MAX_COMMAND_ARGS = 256;
|
||||
const MAX_HTTP_HEADERS = 256;
|
||||
|
||||
export interface PluginHookHandler {
|
||||
type: string;
|
||||
command?: string;
|
||||
commandWindows?: string;
|
||||
args?: string[];
|
||||
shell?: 'bash' | 'powershell';
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
allowedEnvVars?: string[];
|
||||
server?: string;
|
||||
tool?: string;
|
||||
input?: Record<string, unknown>;
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
timeout?: number;
|
||||
statusMessage?: string;
|
||||
if?: string;
|
||||
once?: boolean;
|
||||
continueOnBlock?: boolean;
|
||||
async?: boolean;
|
||||
asyncRewake?: boolean;
|
||||
rewakeMessage?: string;
|
||||
rewakeSummary?: string;
|
||||
}
|
||||
|
||||
export interface PluginHookGroup {
|
||||
matcher?: string;
|
||||
if?: string;
|
||||
hooks: PluginHookHandler[];
|
||||
}
|
||||
|
||||
export interface PluginHooksDocument {
|
||||
$schema?: string;
|
||||
description?: string;
|
||||
hooks: Record<string, PluginHookGroup[]>;
|
||||
}
|
||||
|
||||
export const pluginHookHandlerSchema: z.ZodType<PluginHookHandler> = z
|
||||
.object({
|
||||
type: z.string().trim().min(1).max(MAX_HANDLER_TYPE_LENGTH),
|
||||
command: z.string().min(1).max(MAX_COMMAND_LENGTH).optional(),
|
||||
commandWindows: z.string().min(1).max(MAX_COMMAND_LENGTH).optional(),
|
||||
args: z.array(z.string().max(MAX_COMMAND_LENGTH)).max(MAX_COMMAND_ARGS).optional(),
|
||||
shell: z.enum(['bash', 'powershell']).optional(),
|
||||
url: z.string().trim().min(1).max(MAX_COMMAND_LENGTH).optional(),
|
||||
headers: z
|
||||
.record(
|
||||
z.string().trim().min(1).max(MAX_STATUS_MESSAGE_LENGTH),
|
||||
z.string().max(MAX_COMMAND_LENGTH),
|
||||
)
|
||||
.refine((headers) => Object.keys(headers).length <= MAX_HTTP_HEADERS, {
|
||||
message: `HTTP hooks may declare at most ${MAX_HTTP_HEADERS} headers`,
|
||||
})
|
||||
.optional(),
|
||||
allowedEnvVars: z
|
||||
.array(z.string().trim().min(1).max(MAX_STATUS_MESSAGE_LENGTH))
|
||||
.max(MAX_COMMAND_ARGS)
|
||||
.optional(),
|
||||
server: z.string().trim().min(1).max(MAX_STATUS_MESSAGE_LENGTH).optional(),
|
||||
tool: z.string().trim().min(1).max(MAX_STATUS_MESSAGE_LENGTH).optional(),
|
||||
input: z.record(z.string(), z.unknown()).optional(),
|
||||
prompt: z.string().min(1).max(MAX_COMMAND_LENGTH).optional(),
|
||||
model: z.string().trim().min(1).max(MAX_STATUS_MESSAGE_LENGTH).optional(),
|
||||
timeout: z.number().int().positive().max(MAX_TIMEOUT_SECONDS).optional(),
|
||||
statusMessage: z.string().max(MAX_STATUS_MESSAGE_LENGTH).optional(),
|
||||
if: z.string().trim().min(1).max(MAX_COMMAND_LENGTH).optional(),
|
||||
once: z.boolean().optional(),
|
||||
continueOnBlock: z.boolean().optional(),
|
||||
async: z.boolean().optional(),
|
||||
asyncRewake: z.boolean().optional(),
|
||||
rewakeMessage: z.string().max(MAX_DESCRIPTION_LENGTH).optional(),
|
||||
rewakeSummary: z.string().max(MAX_STATUS_MESSAGE_LENGTH).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((handler, context) => {
|
||||
if (handler.type === 'command' && !handler.command?.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['command'],
|
||||
message: 'Command hooks require a non-empty command',
|
||||
});
|
||||
}
|
||||
if (handler.type === 'prompt' && !handler.prompt?.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['prompt'],
|
||||
message: 'Prompt hooks require a non-empty prompt',
|
||||
});
|
||||
}
|
||||
if (handler.type === 'agent' && !handler.prompt?.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['prompt'],
|
||||
message: 'Agent hooks require a non-empty prompt',
|
||||
});
|
||||
}
|
||||
if (handler.type === 'http' && !handler.url) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['url'],
|
||||
message: 'HTTP hooks require a non-empty URL',
|
||||
});
|
||||
}
|
||||
if (handler.type === 'mcp_tool' && !handler.server) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['server'],
|
||||
message: 'MCP tool hooks require a non-empty server',
|
||||
});
|
||||
}
|
||||
if (handler.type === 'mcp_tool' && !handler.tool) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['tool'],
|
||||
message: 'MCP tool hooks require a non-empty tool',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const pluginHookGroupSchema: z.ZodType<PluginHookGroup> = z
|
||||
.object({
|
||||
matcher: z.string().trim().max(MAX_PATTERN_LENGTH).optional(),
|
||||
/** Retained for older hook bundles; current Claude plugins declare `if` per handler. */
|
||||
if: z.string().trim().min(1).max(MAX_COMMAND_LENGTH).optional(),
|
||||
hooks: z.array(pluginHookHandlerSchema).min(1).max(MAX_HANDLERS_PER_GROUP),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const pluginHookEventsSchema = z.record(
|
||||
z.string().trim().min(1).max(MAX_EVENT_NAME_LENGTH),
|
||||
z.array(pluginHookGroupSchema).min(1).max(MAX_GROUPS_PER_EVENT),
|
||||
);
|
||||
|
||||
export const pluginHooksDocumentSchema: z.ZodType<PluginHooksDocument> = z
|
||||
.object({
|
||||
$schema: z.string().trim().min(1).max(MAX_COMMAND_LENGTH).optional(),
|
||||
description: z.string().max(MAX_DESCRIPTION_LENGTH).optional(),
|
||||
hooks: pluginHookEventsSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((document, context) => {
|
||||
let total = 0;
|
||||
for (const groups of Object.values(document.hooks)) {
|
||||
for (const group of groups) {
|
||||
total += group.hooks.length;
|
||||
if (total <= MAX_TOTAL_HANDLERS) {
|
||||
continue;
|
||||
}
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.too_big,
|
||||
type: 'array',
|
||||
maximum: MAX_TOTAL_HANDLERS,
|
||||
inclusive: true,
|
||||
path: ['hooks'],
|
||||
message: `Hook documents may declare at most ${MAX_TOTAL_HANDLERS} handlers`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export interface PluginHookValidationIssue {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type PluginHooksParseResult =
|
||||
| { success: true; document: PluginHooksDocument }
|
||||
| { success: false; issues: PluginHookValidationIssue[] };
|
||||
|
||||
/** Parse Claude's plugin-specific `{"hooks": {...}}` wrapper. */
|
||||
export function parsePluginHooks(input: unknown): PluginHooksParseResult {
|
||||
const parsed = pluginHooksDocumentSchema.safeParse(input);
|
||||
if (parsed.success) {
|
||||
return { success: true, document: parsed.data };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
issues: parsed.error.issues.map((issue) => ({
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ export * from './validation';
|
|||
export * from './added';
|
||||
export * from './load';
|
||||
export * from './hitl';
|
||||
export * from './hooks';
|
||||
export * from './steering';
|
||||
export * from './activityLabels';
|
||||
export * from './toolValidation';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue