返回 CodeWhale
harness-hooks.test.mjs
根目录 / crates / tui / extension-host / test / harness-hooks.test.mjs
1 import test from 'node:test'
2 import assert from 'node:assert/strict'
3 import {realpathSync,mkdtempSync,writeFileSync,mkdirSync,symlinkSync,rmSync} from 'node:fs'
4 import {tmpdir} from 'node:os'
5 import {join} from 'node:path'
6 import {createHash} from 'node:crypto'
7 import {createHarnessModule} from '../dist/builtin/harness.mjs'
8 import {reviewedHookModule} from '../dist/shell-hooks.mjs'
9 const owner={plugin_id:'host:harness',generation:3,owner_token:'receipt'}
10 const all=['session_start','session_end','message_submit','tool_call_before','tool_call_after','mode_change','on_error','turn_end','subagent_spawn','subagent_complete','shell_env','session_idle','session_error','waiting_for_user','session_busy']
11 const metadata=(event='tool_call_before',dialect='claude-code',point='PreToolUse',more={})=>({event,dialect,point,query:'write',...more})
12 const params=(hook,more={})=>({owner,execution_id:'one',ticket:'opaque',deadline_ms:1000,hook,...more})
13 const output=(event,stdout='',exit_code=0,stderr='')=>({kind:'hook',event,success:exit_code===0,exit_code,stdout,stderr})
14 const signal=()=>new AbortController().signal
15 function fixture(raw){const root=realpathSync(mkdtempSync(join(tmpdir(),'cw-hook-bridge-')));const bytes=JSON.stringify(raw);writeFileSync(join(root,'hooks.json'),bytes);return {root,files:{'hooks.json':createHash('sha256').update(bytes).digest('hex')},cleanup:()=>rmSync(root,{recursive:true,force:true})}}
16 function context(){const rows=[];const warnings=[];return {rows,warnings,shellHooks:{register(row){rows.push(row);return ()=>{} }},logger:{warn(text){warnings.push(text)}}}}
17
18 test('all 15 existing core firepoint projections complete using one opaque Execution RPC',async()=>{
19 for(const event of all){const calls=[];const module=createHarnessModule({async request(method,args){calls.push([method,args]);return {kind:'hook',event,success:true,exit_code:0,...(event==='shell_env'?{keys:['PATH','TOKEN']}:{})}}},owner)
20 assert.deepEqual(await module.run(params(metadata(event,'codewhale',event)),signal()),{ok:true,hook_completed:true});assert.deepEqual(calls,[['exec/redeem',{owner,execution_id:'one',ticket:'opaque'}]]);await module.dispose()}
21 })
22 test('ShellEnv read-only projection refuses private stdout, stderr and unrelated process data',async()=>{
23 for(const extra of [{stdout:'TOKEN=private'}, {stderr:'private'}, {environment:{TOKEN:'private'}}]){const module=createHarnessModule({async request(){return {kind:'hook',event:'shell_env',success:true,exit_code:0,keys:['TOKEN'],...extra}}},owner);await assert.rejects(module.run(params(metadata('shell_env','codewhale','shell_env')),signal()),/private process data/);await module.dispose()}
24 })
25 test('exact pinned Claude literal and Codex regex matcher differences govern actual redemption',async()=>{
26 let calls=0;const module=createHarnessModule({async request(){calls++;return output('tool_call_before','{}')}},owner)
27 assert.deepEqual(await module.run(params(metadata('tool_call_before','claude-code','PreToolUse',{matcher:'write',query:'write_file'})),signal()),{ok:true,hook_skipped:true});assert.equal(calls,0)
28 assert.equal((await module.run(params(metadata('tool_call_before','codex','PreToolUse',{matcher:'write',query:'write_file'})),signal())).hook_completed,true);assert.equal(calls,1);await module.dispose()
29 })
30 test('hookSpecificOutput discriminator fences permission proposals and allow never approves',async()=>{
31 let stdout=JSON.stringify({hookSpecificOutput:{hookEventName:'PostToolUse',permissionDecision:'deny'}});const module=createHarnessModule({async request(){return output('tool_call_before',stdout)}},owner)
32 assert.deepEqual(JSON.parse((await module.run(params(metadata()),signal())).proposal),{})
33 stdout=JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow'}});assert.deepEqual(JSON.parse((await module.run(params(metadata()),signal())).proposal),{});await module.dispose()
34 })
35 test('Claude ask maps to Rust proposal while Codex ignores ask; exit2 block is retained',async()=>{
36 let exit=0;let stdout=JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'ask',permissionDecisionReason:'confirm'}});const module=createHarnessModule({async request(){return output('tool_call_before',stdout,exit,'reason')}},owner)
37 assert.deepEqual(JSON.parse((await module.run(params(metadata()),signal())).proposal),{decision:'ask',reason:'confirm'})
38 assert.deepEqual(JSON.parse((await module.run(params(metadata('tool_call_before','codex')),signal())).proposal),{})
39 exit=2;stdout='';assert.deepEqual(JSON.parse((await module.run(params(metadata()),signal())).proposal),{decision:'deny',reason:'reason'});await module.dispose()
40 })
41 test('prompt block is a bounded submit proposal without falsifying process exit',async()=>{
42 const module=createHarnessModule({async request(){return output('message_submit',JSON.stringify({decision:'block',reason:'blocked'}))}},owner)
43 assert.deepEqual(JSON.parse((await module.run(params(metadata('message_submit','claude-code','UserPromptSubmit')),signal())).proposal),{block:true,reason:'blocked'});await module.dispose()
44 })
45 test('observer steering is explicitly unsupported and malformed/oversized output refuses',async()=>{
46 for(const stdout of [JSON.stringify({continue:false}),JSON.stringify({decision:'block'}),'x'.repeat(65537)]){const module=createHarnessModule({async request(){return output('turn_end',stdout)}},owner);await assert.rejects(module.run(params(metadata('turn_end','claude-code','Stop')),signal()),/unavailable|invalid dialect/);await module.dispose()}
47 })
48 test('hook deadline cancellation and disposal settle an unresponsive broker without fallback',async()=>{
49 const module=createHarnessModule({request(){return new Promise(()=>{})}},owner);await assert.rejects(module.run(params(metadata(),{deadline_ms:5}),signal()),/cancelled/)
50 const pending=module.run(params(metadata()),signal());const rejection=assert.rejects(pending,/cancelled/);await module.dispose();await rejection
51 })
52 test('reviewed Claude config uses pinned parser and creates real core catalog contribution definitions',()=>{
53 const f=fixture({hooks:{PreToolUse:[{matcher:'write|edit',hooks:[{type:'command',command:'${CLAUDE_PLUGIN_ROOT}/check',timeout:3}]}],SubagentStart:[{hooks:[{command:'true'}]}],Stop:[{hooks:[{command:'true'}]}]}})
54 try{const ctx=context();reviewedHookModule('claude-code',f.root,f.files).apply(ctx,{configPath:'hooks.json'});assert.equal(ctx.rows.length,3);assert.equal(ctx.rows[0].hook.command,`${f.root}/check`);assert.equal(ctx.rows[0].matcher,'write|edit');assert.equal(ctx.rows[0].hook.timeout_secs,3);assert.equal(ctx.rows[1].hook.event,'turn_end');assert.equal(ctx.warnings.length,1);assert.equal(ctx.rows[2].hook.event,'subagent_spawn')}finally{f.cleanup()}
55 })
56 test('changed bytes, external path, symlink and unsupported async/noncommand config never register',()=>{
57 for(const dialect of ['claude-code','codex']){const f=fixture({hooks:{PreToolUse:[{hooks:[{command:'true',...(dialect==='codex'?{async:true}:{type:'prompt'})}]}]}});try{const ctx=context();assert.throws(()=>reviewedHookModule(dialect,f.root,f.files).apply(ctx,{configPath:'hooks.json'}),/unsupported/);assert.equal(ctx.rows.length,0);writeFileSync(join(f.root,'hooks.json'),'{}');assert.throws(()=>reviewedHookModule(dialect,f.root,f.files).apply(ctx,{configPath:'hooks.json'}),/changed/);assert.throws(()=>reviewedHookModule(dialect,f.root,f.files).apply(ctx,{configPath:'../elsewhere.json'}),/closure/)}finally{f.cleanup()}}
58 const f=fixture({hooks:{PreToolUse:[{hooks:[{command:'true'}]}]}});try{symlinkSync(join(f.root,'hooks.json'),join(f.root,'link.json'));const files={...f.files,'link.json':f.files['hooks.json']};assert.throws(()=>reviewedHookModule('claude-code',f.root,files).apply(context(),{configPath:'link.json'}),/closure/)}finally{f.cleanup()}
59 })
60
60 lines Plain Text