Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions bindings/node/__tests__/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,44 @@ describe('JsHookRegistry', () => {
expect(parsed).toHaveProperty('custom', 'value')
expect(parsed).toHaveProperty('tool', 'grep')
})

it('supports async handlers returning Promise<string>', async () => {
const registry = new JsHookRegistry()
let handlerCalled = false
let receivedEvent = ''

registry.register('tool:pre', async (event: string, _data: string) => {
handlerCalled = true
receivedEvent = event
// Simulate async work (e.g. I/O)
await new Promise<void>(resolve => setImmediate(() => resolve()))

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simplified to avoid an extra closure (e.g., pass resolve directly to setImmediate) while keeping the same behavior. It also makes the intent of ‘await one tick’ a bit clearer.

Suggested change
await new Promise<void>(resolve => setImmediate(() => resolve()))
await new Promise<void>(resolve => setImmediate(resolve))

Copilot uses AI. Check for mistakes.
return JSON.stringify({ action: 'continue' })
}, 10, 'async-hook')

const result = await registry.emit('tool:pre', '{"tool":"grep"}')

expect(handlerCalled).toBe(true)
expect(receivedEvent).toBe('tool:pre')
expect(result.action).toBe(HookAction.Continue)
})

it('async handler returning deny short-circuits pipeline', async () => {
const registry = new JsHookRegistry()
let secondRan = false

registry.register('tool:pre', async (_event: string, _data: string) => {
await new Promise<void>(resolve => setImmediate(() => resolve()))
return JSON.stringify({ action: 'deny', reason: 'async blocked' })
}, 10, 'async-deny')
registry.register('tool:pre', (_event: string, _data: string) => {
secondRan = true
return JSON.stringify({ action: 'continue' })
}, 20, 'after')

const result = await registry.emit('tool:pre', '{"tool":"rm"}')

expect(result.action).toBe(HookAction.Deny)
expect(result.reason).toBe('async blocked')
expect(secondRan).toBe(false)
})
})
12 changes: 11 additions & 1 deletion bindings/node/src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,24 @@ impl HookHandler for JsHookHandlerBridge {
"{}".to_string()
});
Box::pin(async move {
let result_str: String =
// The JS handler may return either a bare string (synchronous) or a
// Promise<String> (async). `call_async` returns whatever the JS
// function returned — accept both via `Either`.
let ret: Either<String, napi::bindgen_prelude::Promise<String>> =
self.callback
.call_async((event, data_str))
.await
Comment on lines +46 to 52

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change fixes the common async case, but any handler returning an unexpected type (e.g., an object) can still trigger a process-level fatal error if the underlying ThreadsafeFunction is configured with ErrorStrategy::Fatal (conversion into Either<...> would fail). Consider switching this bridge’s TSFN to a non-fatal strategy (e.g., CalleeHandled) and mapping conversion failures into HookError::HandlerFailed so user-land mistakes don’t hard-crash the host process.

Copilot uses AI. Check for mistakes.
.map_err(|e| HookError::HandlerFailed {
message: e.to_string(),
handler_name: None,
})?;
let result_str: String = match ret {
Either::A(s) => s,
Either::B(promise) => promise.await.map_err(|e| HookError::HandlerFailed {
message: e.to_string(),
handler_name: None,
})?,
};
Comment on lines +57 to +63

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HookError::HandlerFailed { message: e.to_string(), handler_name: None } mapping is duplicated (once for call_async(...).await and again for promise.await). To keep error handling consistent and reduce repetition, consider extracting this mapping into a small local closure/helper used in both places.

Copilot uses AI. Check for mistakes.
let hook_result: HookResult = serde_json::from_str(&result_str).unwrap_or_else(|e| {
log::error!(
"SECURITY: Hook handler returned unparseable result — failing closed (Deny): {e} — json: {result_str}"
Expand Down
Loading