/** * Tests that the hooks route profile state through CLAUDE_CONFIG_DIR * * Run with: node tests/hooks/claude-config-dir-routing.test.js * * These spawn the hooks rather than calling the shared resolver. The four hooks * below cannot require scripts/lib/agent-data-home.js: managed installs ship a * top-level scripts/ without scripts/lib/ (#3259), so each carries its own copy * of the resolver. A resolver test therefore cannot show where a hook writes. */ const assert = require('assert'); const path = require('path'); const fs = require('fs'); const os = require('os'); const { spawnSync } = require('child_process'); const hooksDir = path.join(__dirname, '..', '..', 'scripts', 'hooks'); const libSessions = path.join(__dirname, '..', '..', 'scripts', 'lib', 'plan-canvas', 'sessions.js'); function test(name, fn) { try { fn(); console.log(` ✓ ${name}`); return true; } catch (err) { console.log(` ✗ ${name}`); console.log(` Error: ${err.message}`); return false; } } function makeTempDir() { return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'config-dir-routing-'))); } /** * A profile that is not the home directory, so a hook that ignored * CLAUDE_CONFIG_DIR would write somewhere this returns. */ function makeProfile() { const root = makeTempDir(); return { home: path.join(root, 'home'), profile: path.join(root, 'profile', '.claude-work'), }; } // No ECC_* override is passed anywhere here: an override would route the hook // before it reached the CLAUDE_CONFIG_DIR fallback under test. function runHook(hookFile, { profile, home, stdin = '{}', args = [], env = {} }) { fs.mkdirSync(home, { recursive: true }); return spawnSync('node', [path.join(hooksDir, hookFile), ...args], { input: stdin, encoding: 'utf8', env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: profile, ECC_PLAN_CANVAS_STATE_DIR: undefined, ECC_MCP_HEALTH_STATE_PATH: undefined, ...env, }, }); } /** * plan-canvas-pending.js exports run() and is required by the lifecycle hook * bootstrap rather than spawned, so it is driven here in a child process. The * child is what makes CLAUDE_CONFIG_DIR meaningful: requiring it in-process, * the way tests/hooks/plan-canvas-pending-hook.test.js does with * ECC_PLAN_CANVAS_STATE_DIR, cannot reach the fallback under test. */ function runPendingHook({ profile, home, payload }) { fs.mkdirSync(home, { recursive: true }); const result = spawnSync('node', ['-e', ` require(${JSON.stringify(path.join(hooksDir, 'plan-canvas-pending.js'))}) .run(${JSON.stringify(JSON.stringify(payload))}) .then(out => process.stdout.write(out.stdout)); `], { encoding: 'utf8', env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: profile, ECC_PLAN_CANVAS_STATE_DIR: undefined, }, }); assert.strictEqual(result.status, 0, `pending hook failed: ${result.stderr}`); return result.stdout; } function writeSessions(stateDir, sessions) { fs.mkdirSync(stateDir, { recursive: true }); fs.writeFileSync(path.join(stateDir, 'sessions.json'), JSON.stringify({ sessions }, null, 2)); } function runTests() { console.log('\nTesting CLAUDE_CONFIG_DIR routing in hooks\n'); let passed = 0; let failed = 0; const record = ok => { if (ok) passed++; else failed++; }; console.log('post-bash-command-log.js'); record(test('writes the audit log under CLAUDE_CONFIG_DIR', () => { const { home, profile } = makeProfile(); runHook('post-bash-command-log.js', { home, profile, args: ['audit'], stdin: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'echo routed' } }), }); const log = path.join(profile, 'bash-commands.log'); assert.ok(fs.existsSync(log), `expected ${log} to exist`); assert.match(fs.readFileSync(log, 'utf8'), /echo routed/); assert.ok( !fs.existsSync(path.join(home, '.claude', 'bash-commands.log')), 'the log must not also land in ~/.claude' ); })); record(test('writes the cost log under CLAUDE_CONFIG_DIR', () => { const { home, profile } = makeProfile(); runHook('post-bash-command-log.js', { home, profile, args: ['cost'], stdin: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'echo costed' } }), }); assert.match(fs.readFileSync(path.join(profile, 'cost-tracker.log'), 'utf8'), /echo costed/); })); record(test('falls back to ~/.claude when CLAUDE_CONFIG_DIR is unset', () => { const { home } = makeProfile(); fs.mkdirSync(home, { recursive: true }); spawnSync('node', [path.join(hooksDir, 'post-bash-command-log.js'), 'audit'], { input: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'echo default' } }), encoding: 'utf8', env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: undefined }, }); assert.match( fs.readFileSync(path.join(home, '.claude', 'bash-commands.log'), 'utf8'), /echo default/ ); })); record(test('expands a tilde profile instead of creating a "~" directory', () => { // path.resolve('~/.claude-work') would make ./~/.claude-work under the cwd, // splitting the hook off from scripts/lib/agent-data-home.js, which expands. const { home } = makeProfile(); runHook('post-bash-command-log.js', { home, profile: '~/.claude-work', args: ['audit'], stdin: JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'echo tilde' } }), }); assert.match( fs.readFileSync(path.join(home, '.claude-work', 'bash-commands.log'), 'utf8'), /echo tilde/ ); })); console.log('\nmcp-health-check.js'); record(test('writes the health cache under CLAUDE_CONFIG_DIR', () => { const { home, profile } = makeProfile(); fs.mkdirSync(home, { recursive: true }); // Without a configured server the hook skips the probe and never writes. // The command does not exist, so the probe fails and the result is cached. fs.writeFileSync(path.join(home, '.claude.json'), JSON.stringify({ mcpServers: { routing_probe: { command: 'ecc-no-such-mcp-binary', args: [] } }, })); runHook('mcp-health-check.js', { home, profile, stdin: JSON.stringify({ tool_name: 'mcp__routing_probe__ping', tool_input: {} }), }); const cache = path.join(profile, 'mcp-health-cache.json'); assert.ok(fs.existsSync(cache), `expected ${cache} to exist`); assert.ok( !fs.existsSync(path.join(home, '.claude', 'mcp-health-cache.json')), 'the cache must not also land in ~/.claude' ); })); console.log('\nplan-canvas-sessions.js (SessionStart)'); record(test('reports open sessions recorded under CLAUDE_CONFIG_DIR', () => { const { home, profile } = makeProfile(); writeSessions(path.join(profile, 'plan-canvas'), { k1: { file: '/work/plan.md', status: 'open', pendingFeedback: [] }, }); const result = runHook('plan-canvas-sessions.js', { home, profile }); assert.match(result.stdout, /\/work\/plan\.md/); })); record(test('does not read ~/.claude when a profile is set', () => { const { home, profile } = makeProfile(); // Only the home copy holds a session. A hook that ignored the profile // would announce it; the right answer here is silence. writeSessions(path.join(home, '.claude', 'plan-canvas'), { k1: { file: '/work/leaked.md', status: 'open', pendingFeedback: [] }, }); const result = runHook('plan-canvas-sessions.js', { home, profile }); // Silence is the assertion here, and a hook that crashed before writing is // also silent, so the exit status has to be checked first. assert.strictEqual(result.status, 0, `SessionStart failed: ${result.stderr}`); assert.strictEqual(result.stdout.trim(), ''); })); console.log('\nplan-canvas-pending.js (Stop)'); record(test('blocks on feedback queued under CLAUDE_CONFIG_DIR', () => { const { home, profile } = makeProfile(); const cwd = makeTempDir(); const artifact = path.join(cwd, 'plan.md'); fs.writeFileSync(artifact, '# plan\n'); const stateDir = path.join(profile, 'plan-canvas'); const store = require(libSessions).createSessionStore({ stateDir }); const { session } = store.open(artifact); assert.match(session.key, /^[a-f0-9]{12}$/, 'use the canonical producer session identifier'); store.queueFeedback(session.key, [{ kind: 'verdict', verdict: 'request-changes', text: 'tighten step 2' }]); const decision = JSON.parse(runPendingHook({ home, profile, payload: { cwd } })); assert.strictEqual(decision.decision, 'block'); assert.match(decision.reason, /tighten step 2/); const persisted = JSON.parse(fs.readFileSync(path.join(stateDir, 'sessions.json'), 'utf8')); assert.deepStrictEqual(persisted.sessions[session.key].pendingFeedback, [], 'profile queue is actually drained'); })); console.log('\nproducer to consumer'); record(test('a session the library records is found by both hooks', () => { // The regression this guards: the library wrote ~/.claude/plan-canvas while // the hooks read CLAUDE_CONFIG_DIR/plan-canvas, so under a second profile // SessionStart missed open reviews and Stop let a turn end with feedback // still queued. Both ends are exercised with no state-directory override. const { home, profile } = makeProfile(); const cwd = makeTempDir(); const artifact = path.join(cwd, 'design.md'); fs.writeFileSync(artifact, '# design\n'); fs.mkdirSync(home, { recursive: true }); const producer = spawnSync('node', ['-e', ` const store = require(${JSON.stringify(libSessions)}).createSessionStore(); const { session } = store.open(${JSON.stringify(artifact)}); store.queueFeedback(session.key, [{ kind: 'verdict', verdict: 'request-changes', text: 'needs a rollback plan' }]); `], { encoding: 'utf8', env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: profile, ECC_PLAN_CANVAS_STATE_DIR: undefined, }, }); assert.strictEqual(producer.status, 0, `producer failed: ${producer.stderr}`); assert.ok( fs.existsSync(path.join(profile, 'plan-canvas', 'sessions.json')), 'the library must record the session under the profile' ); const sessionStart = runHook('plan-canvas-sessions.js', { home, profile }); assert.match(sessionStart.stdout, /design\.md/, 'SessionStart must see the open review'); const decision = JSON.parse(runPendingHook({ home, profile, payload: { cwd } })); assert.strictEqual(decision.decision, 'block'); assert.match(decision.reason, /needs a rollback plan/); })); console.log(`\n${'='.repeat(50)}`); console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); console.log('='.repeat(50)); if (failed > 0) process.exit(1); } runTests();