|
@@ -45,17 +45,22 @@ function writeConfig(hooks: unknown, scripts: Record<string, string> = {}): stri
|
|
|
return dir
|
|
return dir
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function harness(configDir: string, adapter: MockAdapter): Promise<Context> {
|
|
|
|
|
- return (await harnessWithFiber(configDir, adapter)).ctx
|
|
|
|
|
|
|
+async function harness(configDir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise<Context> {
|
|
|
|
|
+ return (await harnessWithFiber(configDir, adapter, beforeHooks)).ctx
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** {@link harness}, also exposing the bridge's fiber for tests that dispose it. */
|
|
/** {@link harness}, also exposing the bridge's fiber for tests that dispose it. */
|
|
|
-async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promise<{ ctx: Context; hooks: Fiber }> {
|
|
|
|
|
|
|
+async function harnessWithFiber(
|
|
|
|
|
+ configDir: string,
|
|
|
|
|
+ adapter: MockAdapter,
|
|
|
|
|
+ beforeHooks?: (ctx: Context) => void,
|
|
|
|
|
+): Promise<{ ctx: Context; hooks: Fiber }> {
|
|
|
const ctx = new Context()
|
|
const ctx = new Context()
|
|
|
await mountAgentLoopTestDependencies(ctx)
|
|
await mountAgentLoopTestDependencies(ctx)
|
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
|
await ctx.plugin(LocalSubprocessService)
|
|
await ctx.plugin(LocalSubprocessService)
|
|
|
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
|
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
|
|
|
|
+ beforeHooks?.(ctx)
|
|
|
const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
|
|
const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
|
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
return { ctx, hooks }
|
|
return { ctx, hooks }
|
|
@@ -85,13 +90,14 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
|
|
|
|
|
|
|
|
describe('hooks-claude bridge — UserPromptSubmit', () => {
|
|
describe('hooks-claude bridge — UserPromptSubmit', () => {
|
|
|
it('a UserPromptSubmit hook that exits 2 rejects admission without a turn', async () => {
|
|
it('a UserPromptSubmit hook that exits 2 rejects admission without a turn', async () => {
|
|
|
- // The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr.
|
|
|
|
|
|
|
+ // UserPromptSubmit ignores its malformed matcher field, then exit 2 blocks
|
|
|
|
|
+ // with the reason on stderr.
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
|
|
dirs.push(dir)
|
|
dirs.push(dir)
|
|
|
const block = join(dir, 'block.sh')
|
|
const block = join(dir, 'block.sh')
|
|
|
writeFileSync(block, '#!/usr/bin/env bash\necho "prompt denied by policy" >&2\nexit 2\n')
|
|
writeFileSync(block, '#!/usr/bin/env bash\necho "prompt denied by policy" >&2\nexit 2\n')
|
|
|
chmodSync(block, 0o755)
|
|
chmodSync(block, 0o755)
|
|
|
- writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: block }] }] } }))
|
|
|
|
|
|
|
+ writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: block }] }] } }))
|
|
|
|
|
|
|
|
const adapter = new MockAdapter([textResponse('should not run')])
|
|
const adapter = new MockAdapter([textResponse('should not run')])
|
|
|
const ctx = await harness(dir, adapter)
|
|
const ctx = await harness(dir, adapter)
|
|
@@ -361,6 +367,42 @@ describe('hooks-claude bridge — load resilience', () => {
|
|
|
expect(adapter.requests).toHaveLength(1)
|
|
expect(adapter.requests).toHaveLength(1)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+ it('an invalid regex matcher is reported and registers no hooks', async () => {
|
|
|
|
|
+ const dir = writeConfig({
|
|
|
|
|
+ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
|
|
|
|
|
+ PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 2' }] }],
|
|
|
|
|
+ })
|
|
|
|
|
+ const adapter = new MockAdapter([textResponse('fine')])
|
|
|
|
|
+ const warn = vi.fn()
|
|
|
|
|
+ const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
|
|
|
|
|
+ const agent = ctx.agentLoop.create(SessionId('invalid-claude-matcher'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
|
|
|
|
+ await waitForIdle(ctx, agent)
|
|
|
|
|
+ expect(adapter.requests).toHaveLength(1)
|
|
|
|
|
+ expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false)
|
|
|
|
|
+
|
|
|
|
|
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining(
|
|
|
|
|
+ 'invalid claude regex matcher "(" on event "PreToolUse"',
|
|
|
|
|
+ ))
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('an invalid matcher on an unsupported event does not disable supported hooks', async () => {
|
|
|
|
|
+ const dir = writeConfig({
|
|
|
|
|
+ Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 0' }] }],
|
|
|
|
|
+ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
|
|
|
|
|
+ })
|
|
|
|
|
+ const adapter = new MockAdapter([textResponse('should not run')])
|
|
|
|
|
+ const warn = vi.fn()
|
|
|
|
|
+ const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
|
|
|
|
|
+ const agent = ctx.agentLoop.create(SessionId('unsupported-claude-matcher'), { provider: 'mock', model: 'mock' })
|
|
|
|
|
+ agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
|
|
|
|
+ await waitForIdle(ctx, agent)
|
|
|
|
|
+
|
|
|
|
|
+ expect(adapter.requests).toHaveLength(0)
|
|
|
|
|
+ expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
|
|
|
|
+ expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('invalid claude regex matcher'))
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
|
|
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
|
|
|
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it
|
|
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it
|
|
|
// would veto the prompt (0 model requests) and log a hook/invoked. Build the
|
|
// would veto the prompt (0 model requests) and log a hook/invoked. Build the
|