From 493ffced46d96745496b54756bdfa4a858fac0d5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 8 Aug 2026 07:13:11 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=9D=20feat:=20Add=20Claude=20Hook=20Co?= =?UTF-8?q?mpatibility=20Layer=20(#14412)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../src/agents/hooks/compatibility.spec.ts | 649 ++++++++++++ .../api/src/agents/hooks/compatibility.ts | 544 ++++++++++ packages/api/src/agents/hooks/index.ts | 3 + packages/api/src/agents/hooks/runtime.spec.ts | 968 ++++++++++++++++++ packages/api/src/agents/hooks/runtime.ts | 473 +++++++++ packages/api/src/agents/hooks/schema.spec.ts | 301 ++++++ packages/api/src/agents/hooks/schema.ts | 199 ++++ packages/api/src/agents/index.ts | 1 + 8 files changed, 3138 insertions(+) create mode 100644 packages/api/src/agents/hooks/compatibility.spec.ts create mode 100644 packages/api/src/agents/hooks/compatibility.ts create mode 100644 packages/api/src/agents/hooks/index.ts create mode 100644 packages/api/src/agents/hooks/runtime.spec.ts create mode 100644 packages/api/src/agents/hooks/runtime.ts create mode 100644 packages/api/src/agents/hooks/schema.spec.ts create mode 100644 packages/api/src/agents/hooks/schema.ts diff --git a/packages/api/src/agents/hooks/compatibility.spec.ts b/packages/api/src/agents/hooks/compatibility.spec.ts new file mode 100644 index 0000000000..99aa5c744a --- /dev/null +++ b/packages/api/src/agents/hooks/compatibility.spec.ts @@ -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); + }); +}); diff --git a/packages/api/src/agents/hooks/compatibility.ts b/packages/api/src/agents/hooks/compatibility.ts new file mode 100644 index 0000000000..f7fb4a5e50 --- /dev/null +++ b/packages/api/src/agents/hooks/compatibility.ts @@ -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([ + ['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([ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionDenied', + 'SubagentStart', + 'SubagentStop', + 'StopFailure', + 'PreCompact', + 'PostCompact', +]); + +const TOOL_NAME_EVENTS = new Set([ + '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; +} + +export interface PluginHookCapabilities { + handlerTypes: ReadonlySet; + 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, + }; +} diff --git a/packages/api/src/agents/hooks/index.ts b/packages/api/src/agents/hooks/index.ts new file mode 100644 index 0000000000..d4ad47f63d --- /dev/null +++ b/packages/api/src/agents/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './schema'; +export * from './runtime'; +export * from './compatibility'; diff --git a/packages/api/src/agents/hooks/runtime.spec.ts b/packages/api/src/agents/hooks/runtime.spec.ts new file mode 100644 index 0000000000..2ec0811873 --- /dev/null +++ b/packages/api/src/agents/hooks/runtime.spec.ts @@ -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, Parameters>; +} { + const execute = jest.fn, Parameters>( + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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); + }); +}); diff --git a/packages/api/src/agents/hooks/runtime.ts b/packages/api/src/agents/hooks/runtime.ts new file mode 100644 index 0000000000..4372e444cc --- /dev/null +++ b/packages/api/src/agents/hooks/runtime.ts @@ -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; + 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; + 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; +} + +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(); + const executedHandlers = new WeakMap>(); + 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() : undefined; + const firedSessionIds = entry.handler.once === true ? new Set() : undefined; + const toolNameTranslator = executor.capabilities.toPluginToolName; + const toPluginToolName = + toolNameTranslator === undefined + ? undefined + : (toolName: string): string => + toolNameTranslator({ + sourceEvent: entry.sourceEvent, + targetEvent, + toolName, + }); + const hook: HookCallback = (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 = { + 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](); + } + }, + }; +} diff --git a/packages/api/src/agents/hooks/schema.spec.ts b/packages/api/src/agents/hooks/schema.spec.ts new file mode 100644 index 0000000000..f23ef37a9c --- /dev/null +++ b/packages/api/src/agents/hooks/schema.spec.ts @@ -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'), + }), + ]), + ); + }); +}); diff --git a/packages/api/src/agents/hooks/schema.ts b/packages/api/src/agents/hooks/schema.ts new file mode 100644 index 0000000000..e5882a9c12 --- /dev/null +++ b/packages/api/src/agents/hooks/schema.ts @@ -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; + allowedEnvVars?: string[]; + server?: string; + tool?: string; + input?: Record; + 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; +} + +export const pluginHookHandlerSchema: z.ZodType = 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 = 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 = 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, + })), + }; +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 46da9f2a8e..2074539372 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -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';