Repository Public
1import fs from 'node:fs';2import os from 'node:os';3import path from 'node:path';4import { spawn } from 'node:child_process';5import { fileURLToPath } from 'node:url';6import { afterAll, beforeAll, describe, expect, it } from 'vitest';7import type { Runtime } from './main.js';8import { HOOK_EVENTS, installClaudeHooks, parseHookRequest, uninstallClaudeHooks } from './hooks.js';9import { collectEvents, makeTestEnv, request, startRuntime, waitUntil, type TestEnv } from './core-testkit.js';1011const HOOK_SCRIPT = fileURLToPath(new URL('../bin/cr-hook', import.meta.url));12const SID = '11111111-2222-4333-8444-555555555555';1314describe('installClaudeHooks / uninstallClaudeHooks (temp settings file only)', () => {15 let dir: string;16 beforeAll(() => {17 dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cr-hooks-'));18 });19 afterAll(() => fs.rmSync(dir, { recursive: true, force: true }));2021 const existing = {22 model: 'opus',23 permissions: { allow: ['Bash(ls:*)'] },24 hooks: {25 PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo pre' }] }],26 SessionStart: [{ hooks: [{ type: 'command', command: '/opt/other/session_start.sh', statusMessage: 'x' }] }],27 },28 };29 const read = (file: string) => JSON.parse(fs.readFileSync(file, 'utf8')) as typeof existing & { hooks: Record<string, { hooks: { command: string }[] }[]> };30 const ours = (file: string, event: string) =>31 (read(file).hooks[event] ?? []).flatMap((g) => g.hooks).filter((h) => h.command.includes('cr-hook'));3233 it('merges our four events, preserves everything else, and is idempotent', async () => {34 const file = path.join(dir, 'settings.json');35 fs.writeFileSync(file, JSON.stringify(existing, null, 2), { mode: 0o640 });36 expect(await installClaudeHooks(file, HOOK_SCRIPT)).toBe(true);37 const after = read(file);38 expect(after.model).toBe('opus');39 expect(after.permissions).toEqual(existing.permissions);40 expect(after.hooks.PreToolUse).toEqual(existing.hooks.PreToolUse);41 expect(after.hooks.SessionStart![0]).toEqual(existing.hooks.SessionStart[0]); // the foreign hook is intact42 for (const event of HOOK_EVENTS) {43 expect(ours(file, event)).toEqual([{ type: 'command', command: `'${HOOK_SCRIPT}'`, timeout: 5 }]);44 }45 expect(fs.statSync(file).mode & 0o777).toBe(0o640); // mode preserved4647 const snapshot = fs.readFileSync(file, 'utf8');48 expect(await installClaudeHooks(file, HOOK_SCRIPT)).toBe(false);49 expect(await installClaudeHooks(file, HOOK_SCRIPT)).toBe(false);50 expect(fs.readFileSync(file, 'utf8')).toBe(snapshot);5152 // A redeploy to another directory replaces the entry instead of adding a second one.53 expect(await installClaudeHooks(file, '/new/deploy dir/bin/cr-hook')).toBe(true);54 for (const event of HOOK_EVENTS) expect(ours(file, event).map((h) => h.command)).toEqual([`'/new/deploy dir/bin/cr-hook'`]);5556 expect(await uninstallClaudeHooks(file)).toBe(true);57 expect(read(file)).toEqual(existing); // exactly the original content58 expect(await uninstallClaudeHooks(file)).toBe(false);59 });6061 it('creates the file when missing and removes its own block cleanly', async () => {62 const file = path.join(dir, 'fresh', 'settings.json');63 expect(await uninstallClaudeHooks(file)).toBe(false);64 expect(fs.existsSync(file)).toBe(false);65 expect(await installClaudeHooks(file, HOOK_SCRIPT)).toBe(true);66 expect(Object.keys(read(file).hooks).sort()).toEqual([...HOOK_EVENTS].sort());67 expect(await uninstallClaudeHooks(file)).toBe(true);68 expect(read(file)).toEqual({});69 });7071 it('refuses to touch an unparsable settings file and odd script paths', async () => {72 const file = path.join(dir, 'broken.json');73 fs.writeFileSync(file, '{ not json');74 await expect(installClaudeHooks(file, HOOK_SCRIPT)).rejects.toThrow();75 expect(fs.readFileSync(file, 'utf8')).toBe('{ not json');76 await expect(installClaudeHooks(path.join(dir, 'x.json'), 'relative/cr-hook')).rejects.toThrow();77 await expect(installClaudeHooks(path.join(dir, 'x.json'), '/abs/other-script')).rejects.toThrow();78 });79});8081describe('parseHookRequest', () => {82 it('extracts conversation id, event and a truncated Notification message from raw hook JSON', () => {83 const raw = { session_id: 'conv-1', hook_event_name: 'Notification', message: 'x'.repeat(500), transcript_path: '/t', cwd: '/c' };84 const parsed = parseHookRequest(raw, SID);85 expect(parsed).toMatchObject({ id: SID, event: 'Notification', conversationId: 'conv-1' });86 expect(parsed.message).toHaveLength(200);87 // `message`-like fields of other events (e.g. a user prompt) are never forwarded.88 expect(parseHookRequest({ hook_event_name: 'UserPromptSubmit', session_id: 'c', prompt: 'secret', message: 'm' }, SID).message).toBeNull();89 expect(parseHookRequest({ hook_event_name: 'Stop' }, SID)).toEqual({ id: SID, event: 'Stop', conversationId: null, message: null });90 });9192 it('accepts the contract body and rejects malformed input', () => {93 expect(parseHookRequest({ sessionId: SID, event: 'Stop', conversationId: 'c9' }, undefined)).toEqual({ id: SID, event: 'Stop', conversationId: 'c9', message: null });94 expect(() => parseHookRequest({ hook_event_name: 'Stop' }, undefined)).toThrow();95 expect(() => parseHookRequest({ hook_event_name: 'Stop' }, 'not-a-uuid')).toThrow();96 expect(() => parseHookRequest({ sessionId: 'nope', event: 'Stop' }, undefined)).toThrow();97 expect(() => parseHookRequest({ hook_event_name: 'bad event!' }, SID)).toThrow();98 expect(() => parseHookRequest('string', SID)).toThrow();99 });100});101102describe('cr-hook script + POST /hooks/claude', () => {103 let env: TestEnv;104 let rt: Runtime;105 beforeAll(async () => {106 env = makeTestEnv();107 rt = await startRuntime(env.config);108 });109 afterAll(async () => {110 await rt.stop();111 env.cleanup();112 });113114 // Async spawn: the runtime under test lives in this process, so the event loop must keep turning.115 const runHook = (extraEnv: Record<string, string>, input: string) =>116 new Promise<{ status: number | null; stdout: string; stderr: string; ms: number }>((resolve, reject) => {117 const started = Date.now();118 const child = spawn(HOOK_SCRIPT, [], { env: { PATH: process.env.PATH ?? '/usr/bin:/bin', ...extraEnv }, stdio: ['pipe', 'pipe', 'pipe'] });119 let stdout = '';120 let stderr = '';121 child.stdout.on('data', (d: Buffer) => (stdout += d.toString()));122 child.stderr.on('data', (d: Buffer) => (stderr += d.toString()));123 child.on('error', reject);124 child.on('close', (status) => resolve({ status, stdout, stderr, ms: Date.now() - started }));125 child.stdin.on('error', () => undefined); // the no-op path exits without reading stdin126 child.stdin.end(input);127 });128129 it('is a silent, fast no-op outside a Claude Remote session', async () => {130 const res = await runHook({ CR_HOOK_SOCKET: env.config.socketPath, CR_HOOK_TOKEN: rt.core.hookToken }, '{"hook_event_name":"Stop"}');131 expect(res).toMatchObject({ status: 0, stdout: '', stderr: '' });132 expect(res.ms).toBeLessThan(1000);133 });134135 it('exits 0 silently even when the runtime is unreachable or the token is wrong', async () => {136 const dead = await runHook({ CR_SESSION_ID: SID, CR_HOOK_SOCKET: '/tmp/cr-no-such-socket.sock', CR_HOOK_TOKEN: 'x' }, '{"hook_event_name":"Stop"}');137 expect(dead).toMatchObject({ status: 0, stdout: '', stderr: '' });138 const wrong = await runHook({ CR_SESSION_ID: SID, CR_HOOK_SOCKET: env.config.socketPath, CR_HOOK_TOKEN: 'wrong' }, '{"hook_event_name":"Stop"}');139 expect(wrong).toMatchObject({ status: 0, stdout: '', stderr: '' });140 });141142 it('relays the hook JSON as a claude.hook event', async () => {143 const events = await collectEvents(env.config.socketPath, env.config.token);144 const res = await runHook(145 { CR_SESSION_ID: SID, CR_HOOK_SOCKET: env.config.socketPath, CR_HOOK_TOKEN: rt.core.hookToken },146 JSON.stringify({ session_id: 'conv-xyz', hook_event_name: 'Stop', transcript_path: '/x', stop_hook_active: false }),147 );148 expect(res).toMatchObject({ status: 0, stdout: '', stderr: '' });149 expect(res.ms).toBeLessThan(1500); // answered, not timed out150 await waitUntil(() => events.events.length > 0, 5000, 'claude.hook event');151 expect(events.events).toEqual([{ type: 'claude.hook', id: SID, event: 'Stop', conversationId: 'conv-xyz', message: null }]);152 events.close();153 });154155 it('the hook token works on /hooks/claude and nowhere else', async () => {156 const sock = env.config.socketPath;157 const hook = rt.core.hookToken;158 const body = { sessionId: SID, event: 'Notification', message: 'hi' };159 expect((await request(sock, 'POST', '/hooks/claude', { token: hook, body })).status).toBe(200);160 expect((await request(sock, 'POST', '/hooks/claude', { token: env.config.token, body })).status).toBe(200);161 expect((await request(sock, 'POST', '/hooks/claude', { token: null, body })).status).toBe(401);162 expect((await request(sock, 'POST', '/hooks/claude', { token: 'nope', body })).status).toBe(401);163 expect((await request(sock, 'POST', '/hooks/claude', { token: hook, body: { event: 'Stop' } })).status).toBe(400);164 for (const [method, url] of [['GET', '/sessions'], ['GET', '/health'], ['GET', '/tasks'], ['GET', '/host/metrics'], ['GET', '/hooks/claude'], ['POST', '/sessions'], ['POST', '/tasks']] as const) {165 expect((await request(sock, method, url, { token: hook, body: method === 'POST' ? {} : undefined })).status, `${method} ${url}`).toBe(401);166 }167 expect(fs.statSync(path.join(env.config.dataDir, 'run', 'hook-token')).mode & 0o777).toBe(0o600);168 expect(fs.readFileSync(path.join(env.config.dataDir, 'run', 'hook-token'), 'utf8').trim()).toBe(hook);169 });170171 it('never installs hooks implicitly (CR_INSTALL_CLAUDE_HOOKS unset)', () => {172 expect(process.env.CR_INSTALL_CLAUDE_HOOKS).toBeUndefined();173 const real = path.join(os.homedir(), '.claude', 'settings.json');174 if (fs.existsSync(real)) expect(fs.readFileSync(real, 'utf8')).not.toContain('cr-hook');175 });176});177