restore hook/result durationMs — review keeps wall-clock audit timing durable

Reverses item 3 of the tighten-hook-protocol-contract RFC per review:
a persistence log is written for future readers, and hook wall-clock
runtime is audit signal (which hook made a turn slow). runHook keeps
its injected now clock and RunHookResult wrapper, the bridges pass the
measured duration through HookResultRecord, the snapshot normalizer
keeps its replay scrub, and the hook fixtures carry the field again.
The RFC records the reversal; the other three prunes stand.
This commit is contained in:
Tianyi Cui
2026-07-04 22:00:12 +08:00
parent 19ae955009
commit 0b97b2f7c1
27 changed files with 122 additions and 85 deletions

View File

@@ -49,68 +49,71 @@ function result(over: Partial<BashRunResult> = {}): BashRunResult {
}
}
const clock = () => { let t = 0; return () => (t += 5) } // +5ms per call → duration 5
describe('runHook — payload + env + stdin plumbing', () => {
it('serializes the payload to stdin (with trailing newline when requested)', async () => {
const { bash, specs } = recordingBash(async () => result({ stdout: { text: '', truncated: false } }))
await runHook(bash, { command: 'my-hook.sh' }, {
payload: { hook_event_name: 'PreToolUse', tool_name: 'Bash' },
defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS,
defaultTimeoutMs: 60000,
trailingNewline: true,
})
}, clock())
expect(specs[0]!.stdin).toBe(JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }) + '\n')
expect(specs[0]!.command).toBe('my-hook.sh')
})
it('omits the trailing newline when trailingNewline is false (Codex)', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, { payload: { a: 1 }, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: false })
await runHook(bash, { command: 'h' }, { payload: { a: 1 }, defaultTimeoutMs: 1000, trailingNewline: false }, clock())
expect(specs[0]!.stdin).toBe('{"a":1}')
})
it('threads env and cwd into the request', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, {
payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work',
trailingNewline: true,
})
payload: {}, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work',
defaultTimeoutMs: 1000, trailingNewline: true,
}, clock())
expect(specs[0]!.env).toEqual({ CLAUDE_PROJECT_DIR: '/proj' })
expect(specs[0]!.workdir).toBe('/work')
})
it('a per-hook timeoutSec (seconds) overrides the reference default', async () => {
it('a per-hook timeoutSec (seconds) overrides the default (ms)', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: true })
await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
expect(specs[0]!.timeoutMs).toBe(3000)
})
it('falls back to options.defaultTimeoutMs when the hook sets none', async () => {
it('falls back to the default timeout when the hook sets none', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1234, trailingNewline: true })
expect(specs[0]!.timeoutMs).toBe(1234)
await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
expect(specs[0]!.timeoutMs).toBe(60000)
expect(DEFAULT_HOOK_TIMEOUT_MS).toBe(600_000) // the CC/Codex reference default (10 minutes)
})
it('passes the abort signal through', async () => {
const controller = new AbortController()
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, signal: controller.signal, trailingNewline: true })
await runHook(bash, { command: 'h' }, { payload: {}, signal: controller.signal, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(specs[0]!.signal).toBe(controller.signal)
})
})
describe('runHook — outcome decoding', () => {
it('decodes a clean exit with structured stdout', async () => {
describe('runHook — outcome decoding + duration', () => {
it('decodes a clean exit with structured stdout and reports a duration', async () => {
const { bash } = recordingBash(async () => result({
exitCode: 0, stdout: { text: JSON.stringify({ decision: 'block', reason: 'no' }), truncated: false },
}))
const output = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: true })
const { output, durationMs } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.decision).toBe('block')
expect(output.reason).toBe('no')
expect(durationMs).toBe(5)
})
it('a signal death (exitCode null) decodes as undefined exit (non-blocking error)', async () => {
const { bash } = recordingBash(async () => result({ exitCode: null, signal: 'SIGKILL', stderr: { text: 'killed', truncated: false } }))
const output = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: true })
const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.exitCode).toBeUndefined()
expect(output.decision).toBeUndefined()
expect(output.stderr).toBe('killed')
@@ -118,7 +121,7 @@ describe('runHook — outcome decoding', () => {
it('an executor rejection (infra fault) becomes a non-blocking error, never throws', async () => {
const { bash } = recordingBash(async () => { throw new Error('bad workdir: ENOENT') })
const output = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: true })
const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.exitCode).toBeUndefined()
expect(output.stderr).toBe('bad workdir: ENOENT')
expect(output.decision).toBeUndefined()
@@ -126,7 +129,7 @@ describe('runHook — outcome decoding', () => {
it('a non-Error rejection is stringified onto stderr', async () => {
const { bash } = recordingBash(async () => { throw 'plain string fault' })
const output = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: true })
const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.stderr).toBe('plain string fault')
})
@@ -135,9 +138,9 @@ describe('runHook — outcome decoding', () => {
exitCode: 0,
stdout: { text: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), truncated: false },
}))
const output = await runHook(bash, { command: 'h' }, {
payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: true, expectedEventName: 'Stop',
})
const { output } = await runHook(bash, { command: 'h' }, {
payload: {}, defaultTimeoutMs: 1000, trailingNewline: true, expectedEventName: 'Stop',
}, clock())
// A PreToolUse block on a Stop hook is malformed → its decision is discarded.
expect(output.hookEventName).toBe('PreToolUse')
expect(output.decision).toBeUndefined()