fix: fold session manager blockers into one candidate

This commit is contained in:
Affaan Mustafa
2026-03-24 23:08:27 -04:00
parent 7726c25e46
commit 1d0aa5ac2a
30 changed files with 1126 additions and 288 deletions

View File

@@ -0,0 +1,101 @@
/**
* Tests for scripts/hooks/config-protection.js via run-with-flags.js
*/
const assert = require('assert');
const path = require('path');
const { spawnSync } = require('child_process');
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runHook(input, env = {}) {
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
const result = spawnSync('node', [runner, 'pre:config-protection', 'scripts/hooks/config-protection.js', 'standard,strict'], {
input: rawInput,
encoding: 'utf8',
env: {
...process.env,
ECC_HOOK_PROFILE: 'standard',
...env
},
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe']
});
return {
code: result.status ?? 0,
stdout: result.stdout || '',
stderr: result.stderr || ''
};
}
function runTests() {
console.log('\n=== Testing config-protection ===\n');
let passed = 0;
let failed = 0;
if (test('blocks protected config file edits through run-with-flags', () => {
const input = {
tool_name: 'Write',
tool_input: {
file_path: '.eslintrc.js',
content: 'module.exports = {};'
}
};
const result = runHook(input);
assert.strictEqual(result.code, 2, 'Expected protected config edit to be blocked');
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`);
})) passed++; else failed++;
if (test('passes through safe file edits unchanged', () => {
const input = {
tool_name: 'Write',
tool_input: {
file_path: 'src/index.js',
content: 'console.log("ok");'
}
};
const rawInput = JSON.stringify(input);
const result = runHook(input);
assert.strictEqual(result.code, 0, 'Expected safe file edit to pass');
assert.strictEqual(result.stdout, rawInput, 'Expected exact raw JSON passthrough');
assert.strictEqual(result.stderr, '', 'Expected no stderr for safe edits');
})) passed++; else failed++;
if (test('blocks truncated protected config payloads instead of failing open', () => {
const rawInput = JSON.stringify({
tool_name: 'Write',
tool_input: {
file_path: '.eslintrc.js',
content: 'x'.repeat(1024 * 1024 + 2048)
}
});
const result = runHook(rawInput);
assert.strictEqual(result.code, 2, 'Expected truncated protected payload to be blocked');
assert.strictEqual(result.stdout, '', 'Blocked truncated payload should not echo raw input');
assert.ok(result.stderr.includes('Hook input exceeded 1048576 bytes'), `Expected size warning, got: ${result.stderr}`);
assert.ok(result.stderr.includes('truncated payload'), `Expected truncated payload warning, got: ${result.stderr}`);
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View File

@@ -156,6 +156,35 @@ async function runTests() {
assert.strictEqual(approvalEvent.payload.severity, 'high');
})) passed += 1; else failed += 1;
if (await test('approval events fingerprint commands instead of storing raw command text', async () => {
const command = 'git push origin main --force';
const events = analyzeForGovernanceEvents({
tool_name: 'Bash',
tool_input: { command },
});
const approvalEvent = events.find(e => e.eventType === 'approval_requested');
assert.ok(approvalEvent);
assert.strictEqual(approvalEvent.payload.commandName, 'git');
assert.ok(/^[a-f0-9]{12}$/.test(approvalEvent.payload.commandFingerprint), 'Expected short command fingerprint');
assert.ok(!Object.prototype.hasOwnProperty.call(approvalEvent.payload, 'command'), 'Should not store raw command text');
})) passed += 1; else failed += 1;
if (await test('security findings fingerprint elevated commands instead of storing raw command text', async () => {
const command = 'sudo chmod 600 ~/.ssh/id_rsa';
const events = analyzeForGovernanceEvents({
tool_name: 'Bash',
tool_input: { command },
}, {
hookPhase: 'post',
});
const securityEvent = events.find(e => e.eventType === 'security_finding');
assert.ok(securityEvent);
assert.strictEqual(securityEvent.payload.commandName, 'sudo');
assert.ok(/^[a-f0-9]{12}$/.test(securityEvent.payload.commandFingerprint), 'Expected short command fingerprint');
assert.ok(!Object.prototype.hasOwnProperty.call(securityEvent.payload, 'command'), 'Should not store raw command text');
})) passed += 1; else failed += 1;
if (await test('analyzeForGovernanceEvents detects sensitive file access', async () => {
const events = analyzeForGovernanceEvents({
tool_name: 'Edit',
@@ -273,6 +302,43 @@ async function runTests() {
}
})) passed += 1; else failed += 1;
if (await test('run() emits hook_input_truncated event without logging raw command text', async () => {
const original = process.env.ECC_GOVERNANCE_CAPTURE;
const originalHookEvent = process.env.CLAUDE_HOOK_EVENT_NAME;
const originalWrite = process.stderr.write;
const stderr = [];
process.env.ECC_GOVERNANCE_CAPTURE = '1';
process.env.CLAUDE_HOOK_EVENT_NAME = 'PreToolUse';
process.stderr.write = (chunk, encoding, callback) => {
stderr.push(String(chunk));
if (typeof encoding === 'function') encoding();
if (typeof callback === 'function') callback();
return true;
};
try {
const input = JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'rm -rf /tmp/important' } });
const result = run(input, { truncated: true, maxStdin: 1024 });
assert.strictEqual(result, input);
} finally {
process.stderr.write = originalWrite;
if (original !== undefined) {
process.env.ECC_GOVERNANCE_CAPTURE = original;
} else {
delete process.env.ECC_GOVERNANCE_CAPTURE;
}
if (originalHookEvent !== undefined) {
process.env.CLAUDE_HOOK_EVENT_NAME = originalHookEvent;
} else {
delete process.env.CLAUDE_HOOK_EVENT_NAME;
}
}
const combined = stderr.join('');
assert.ok(combined.includes('\"eventType\":\"hook_input_truncated\"'), 'Should emit truncation event');
assert.ok(combined.includes('\"sizeLimitBytes\":1024'), 'Should record the truncation limit');
assert.ok(!combined.includes('rm -rf /tmp/important'), 'Should not leak raw command text to governance logs');
})) passed += 1; else failed += 1;
if (await test('run() can detect multiple event types in one input', async () => {
// Bash command with force push AND secret in command
const events = analyzeForGovernanceEvents({

View File

@@ -82,6 +82,25 @@ function sleepMs(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function getCanonicalSessionsDir(homeDir) {
return path.join(homeDir, '.claude', 'session-data');
}
function getLegacySessionsDir(homeDir) {
return path.join(homeDir, '.claude', 'sessions');
}
function getSessionStartAdditionalContext(stdout) {
if (!stdout.trim()) {
return '';
}
const payload = JSON.parse(stdout);
assert.strictEqual(payload.hookSpecificOutput?.hookEventName, 'SessionStart', 'Should emit SessionStart hook payload');
assert.strictEqual(typeof payload.hookSpecificOutput?.additionalContext, 'string', 'Should include additionalContext text');
return payload.hookSpecificOutput.additionalContext;
}
// Test helper
function test(name, fn) {
try {
@@ -336,7 +355,7 @@ async function runTests() {
if (
await asyncTest('exits 0 even with isolated empty HOME', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-iso-start-${Date.now()}`);
fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true });
fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
try {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
@@ -364,7 +383,7 @@ async function runTests() {
if (
await asyncTest('skips template session content', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-tpl-start-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getLegacySessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
@@ -378,8 +397,8 @@ async function runTests() {
USERPROFILE: isoHome
});
assert.strictEqual(result.code, 0);
// stdout should NOT contain the template content
assert.ok(!result.stdout.includes('Previous session summary'), 'Should not inject template session content');
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(!additionalContext.includes('Previous session summary'), 'Should not inject template session content');
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
@@ -391,7 +410,7 @@ async function runTests() {
if (
await asyncTest('injects real session content', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-real-start-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getLegacySessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
@@ -405,8 +424,9 @@ async function runTests() {
USERPROFILE: isoHome
});
assert.strictEqual(result.code, 0);
assert.ok(result.stdout.includes('Previous session summary'), 'Should inject real session content');
assert.ok(result.stdout.includes('authentication refactor'), 'Should include session content text');
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(additionalContext.includes('Previous session summary'), 'Should inject real session content');
assert.ok(additionalContext.includes('authentication refactor'), 'Should include session content text');
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
@@ -418,7 +438,7 @@ async function runTests() {
if (
await asyncTest('strips ANSI escape codes from injected session content', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-ansi-start-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getLegacySessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
@@ -434,9 +454,10 @@ async function runTests() {
USERPROFILE: isoHome
});
assert.strictEqual(result.code, 0);
assert.ok(result.stdout.includes('Previous session summary'), 'Should inject real session content');
assert.ok(result.stdout.includes('Windows terminal handling'), 'Should preserve sanitized session text');
assert.ok(!result.stdout.includes('\x1b['), 'Should not emit ANSI escape codes');
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(additionalContext.includes('Previous session summary'), 'Should inject real session content');
assert.ok(additionalContext.includes('Windows terminal handling'), 'Should preserve sanitized session text');
assert.ok(!additionalContext.includes('\x1b['), 'Should not emit ANSI escape codes');
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
@@ -450,7 +471,7 @@ async function runTests() {
const isoHome = path.join(os.tmpdir(), `ecc-skills-start-${Date.now()}`);
const learnedDir = path.join(isoHome, '.claude', 'skills', 'learned');
fs.mkdirSync(learnedDir, { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true });
fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true });
// Create learned skill files
fs.writeFileSync(path.join(learnedDir, 'testing-patterns.md'), '# Testing');
@@ -548,7 +569,7 @@ async function runTests() {
// Check if session file was created
// Note: Without CLAUDE_SESSION_ID, falls back to project/worktree name (not 'default')
// Use local time to match the script's getDateString() function
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
@@ -581,7 +602,7 @@ async function runTests() {
// Check if session file was created with session ID
// Use local time to match the script's getDateString() function
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const sessionFile = path.join(sessionsDir, `${today}-${expectedShortId}-session.tmp`);
@@ -614,7 +635,7 @@ async function runTests() {
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const sessionFile = path.join(isoHome, '.claude', 'sessions', `${today}-${expectedShortId}-session.tmp`);
const sessionFile = path.join(getCanonicalSessionsDir(isoHome), `${today}-${expectedShortId}-session.tmp`);
const content = fs.readFileSync(sessionFile, 'utf8');
assert.ok(content.includes(`**Project:** ${project}`), 'Should persist project metadata');
@@ -652,7 +673,7 @@ async function runTests() {
if (
await asyncTest('creates compaction log', async () => {
await runScript(path.join(scriptsDir, 'pre-compact.js'));
const logFile = path.join(os.homedir(), '.claude', 'sessions', 'compaction-log.txt');
const logFile = path.join(getCanonicalSessionsDir(os.homedir()), 'compaction-log.txt');
assert.ok(fs.existsSync(logFile), 'Compaction log should exist');
})
)
@@ -662,7 +683,7 @@ async function runTests() {
if (
await asyncTest('annotates active session file with compaction marker', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-compact-annotate-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
// Create an active .tmp session file
@@ -688,7 +709,7 @@ async function runTests() {
if (
await asyncTest('compaction log contains timestamp', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-compact-ts-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
try {
@@ -1544,7 +1565,7 @@ async function runTests() {
assert.strictEqual(result.code, 0, 'Should handle backticks without crash');
// Find the session file in the temp HOME
const claudeDir = path.join(testDir, '.claude', 'sessions');
const claudeDir = getCanonicalSessionsDir(testDir);
if (fs.existsSync(claudeDir)) {
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
if (files.length > 0) {
@@ -1579,7 +1600,7 @@ async function runTests() {
});
assert.strictEqual(result.code, 0);
const claudeDir = path.join(testDir, '.claude', 'sessions');
const claudeDir = getCanonicalSessionsDir(testDir);
if (fs.existsSync(claudeDir)) {
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
if (files.length > 0) {
@@ -1613,7 +1634,7 @@ async function runTests() {
});
assert.strictEqual(result.code, 0);
const claudeDir = path.join(testDir, '.claude', 'sessions');
const claudeDir = getCanonicalSessionsDir(testDir);
if (fs.existsSync(claudeDir)) {
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
if (files.length > 0) {
@@ -1648,7 +1669,7 @@ async function runTests() {
});
assert.strictEqual(result.code, 0);
const claudeDir = path.join(testDir, '.claude', 'sessions');
const claudeDir = getCanonicalSessionsDir(testDir);
if (fs.existsSync(claudeDir)) {
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
if (files.length > 0) {
@@ -1686,7 +1707,7 @@ async function runTests() {
});
assert.strictEqual(result.code, 0);
const claudeDir = path.join(testDir, '.claude', 'sessions');
const claudeDir = getCanonicalSessionsDir(testDir);
if (fs.existsSync(claudeDir)) {
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
if (files.length > 0) {
@@ -1723,7 +1744,7 @@ async function runTests() {
});
assert.strictEqual(result.code, 0);
const claudeDir = path.join(testDir, '.claude', 'sessions');
const claudeDir = getCanonicalSessionsDir(testDir);
if (fs.existsSync(claudeDir)) {
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
if (files.length > 0) {
@@ -1757,7 +1778,7 @@ async function runTests() {
});
assert.strictEqual(result.code, 0);
const claudeDir = path.join(testDir, '.claude', 'sessions');
const claudeDir = getCanonicalSessionsDir(testDir);
if (fs.existsSync(claudeDir)) {
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
if (files.length > 0) {
@@ -1800,7 +1821,7 @@ async function runTests() {
});
assert.strictEqual(result.code, 0);
const claudeDir = path.join(testDir, '.claude', 'sessions');
const claudeDir = getCanonicalSessionsDir(testDir);
if (fs.existsSync(claudeDir)) {
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
if (files.length > 0) {
@@ -1873,9 +1894,8 @@ async function runTests() {
const isNpx = hook.command.startsWith('npx ');
const isSkillScript = hook.command.includes('/skills/') && (/^(bash|sh)\s/.test(hook.command) || hook.command.startsWith('${CLAUDE_PLUGIN_ROOT}/skills/'));
const isHookShellWrapper = /^(bash|sh)\s+["']?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/run-with-flags-shell\.sh/.test(hook.command);
const isSessionStartFallback = hook.command.startsWith('bash -lc') && hook.command.includes('run-with-flags.js');
assert.ok(
isNode || isNpx || isSkillScript || isHookShellWrapper || isSessionStartFallback,
isNode || isNpx || isSkillScript || isHookShellWrapper,
`Hook command should use node or approved shell wrapper: ${hook.command.substring(0, 100)}...`
);
}
@@ -1892,7 +1912,26 @@ async function runTests() {
else failed++;
if (
test('script references use CLAUDE_PLUGIN_ROOT variable (except SessionStart fallback)', () => {
test('SessionStart hook uses safe inline resolver without plugin-tree scanning', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
const sessionStartHook = hooks.hooks.SessionStart?.[0]?.hooks?.[0];
assert.ok(sessionStartHook, 'Should define a SessionStart hook');
assert.ok(sessionStartHook.command.startsWith('node -e "'), 'SessionStart should use inline node resolver');
assert.ok(sessionStartHook.command.includes('session:start'), 'SessionStart should invoke the session:start profile');
assert.ok(sessionStartHook.command.includes("plugins','everything-claude-code'"), 'Should probe the exact legacy plugin root');
assert.ok(sessionStartHook.command.includes("plugins','everything-claude-code@everything-claude-code'"), 'Should probe the namespaced legacy plugin root');
assert.ok(sessionStartHook.command.includes("plugins','marketplace','everything-claude-code'"), 'Should probe the marketplace legacy plugin root');
assert.ok(sessionStartHook.command.includes("plugins','cache','everything-claude-code'"), 'Should retain cache lookup fallback');
assert.ok(!sessionStartHook.command.includes('find '), 'Should not scan arbitrary plugin paths with find');
assert.ok(!sessionStartHook.command.includes('head -n 1'), 'Should not pick the first matching plugin path');
})
)
passed++;
else failed++;
if (
test('script references use CLAUDE_PLUGIN_ROOT variable or safe SessionStart inline resolver', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
@@ -1901,8 +1940,8 @@ async function runTests() {
for (const hook of entry.hooks) {
if (hook.type === 'command' && hook.command.includes('scripts/hooks/')) {
// Check for the literal string "${CLAUDE_PLUGIN_ROOT}" in the command
const isSessionStartFallback = hook.command.startsWith('bash -lc') && hook.command.includes('run-with-flags.js');
const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}') || isSessionStartFallback;
const isSessionStartInlineResolver = hook.command.startsWith('node -e') && hook.command.includes('session:start') && hook.command.includes('run-with-flags.js');
const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}') || isSessionStartInlineResolver;
assert.ok(hasPluginRoot, `Script paths should use CLAUDE_PLUGIN_ROOT: ${hook.command.substring(0, 80)}...`);
}
}
@@ -2766,7 +2805,7 @@ async function runTests() {
if (
await asyncTest('updates Last Updated timestamp in existing session file', async () => {
const testDir = createTestDir();
const sessionsDir = path.join(testDir, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(testDir);
fs.mkdirSync(sessionsDir, { recursive: true });
// Get the expected filename
@@ -2798,7 +2837,7 @@ async function runTests() {
if (
await asyncTest('normalizes existing session headers with project, branch, and worktree metadata', async () => {
const testDir = createTestDir();
const sessionsDir = path.join(testDir, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(testDir);
fs.mkdirSync(sessionsDir, { recursive: true });
const utils = require('../../scripts/lib/utils');
@@ -2831,7 +2870,7 @@ async function runTests() {
if (
await asyncTest('replaces blank template with summary when updating existing file', async () => {
const testDir = createTestDir();
const sessionsDir = path.join(testDir, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(testDir);
fs.mkdirSync(sessionsDir, { recursive: true });
const utils = require('../../scripts/lib/utils');
@@ -2869,7 +2908,7 @@ async function runTests() {
if (
await asyncTest('always updates session summary content on session end', async () => {
const testDir = createTestDir();
const sessionsDir = path.join(testDir, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(testDir);
fs.mkdirSync(sessionsDir, { recursive: true });
const utils = require('../../scripts/lib/utils');
@@ -2906,7 +2945,7 @@ async function runTests() {
if (
await asyncTest('only annotates *-session.tmp files, not other .tmp files', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-compact-glob-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
// Create a session .tmp file and a non-session .tmp file
@@ -2937,7 +2976,7 @@ async function runTests() {
if (
await asyncTest('handles no active session files gracefully', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-compact-nosession-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
try {
@@ -2976,7 +3015,7 @@ async function runTests() {
assert.strictEqual(result.code, 0);
// With no user messages, extractSessionSummary returns null → blank template
const claudeDir = path.join(testDir, '.claude', 'sessions');
const claudeDir = getCanonicalSessionsDir(testDir);
if (fs.existsSync(claudeDir)) {
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
if (files.length > 0) {
@@ -3016,7 +3055,7 @@ async function runTests() {
});
assert.strictEqual(result.code, 0);
const claudeDir = path.join(testDir, '.claude', 'sessions');
const claudeDir = getCanonicalSessionsDir(testDir);
if (fs.existsSync(claudeDir)) {
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
if (files.length > 0) {
@@ -3192,7 +3231,7 @@ async function runTests() {
if (
await asyncTest('exits 0 with empty sessions directory (no recent sessions)', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-start-empty-${Date.now()}`);
fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true });
fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
try {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
@@ -3201,7 +3240,8 @@ async function runTests() {
});
assert.strictEqual(result.code, 0, 'Should exit 0 with no sessions');
// Should NOT inject any previous session data (stdout should be empty or minimal)
assert.ok(!result.stdout.includes('Previous session summary'), 'Should not inject when no sessions');
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(!additionalContext.includes('Previous session summary'), 'Should not inject when no sessions');
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
@@ -3213,7 +3253,7 @@ async function runTests() {
if (
await asyncTest('does not inject blank template session into context', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-start-blank-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
@@ -3229,7 +3269,8 @@ async function runTests() {
});
assert.strictEqual(result.code, 0);
// Should NOT inject blank template
assert.ok(!result.stdout.includes('Previous session summary'), 'Should skip blank template sessions');
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(!additionalContext.includes('Previous session summary'), 'Should skip blank template sessions');
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
@@ -3825,7 +3866,7 @@ async function runTests() {
if (
await asyncTest('annotates only the newest session file when multiple exist', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-compact-multi-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
// Create two session files with different mtimes
@@ -3877,7 +3918,7 @@ async function runTests() {
assert.strictEqual(result.code, 0);
// Find the session file and verify newlines were collapsed
const claudeDir = path.join(testDir, '.claude', 'sessions');
const claudeDir = getCanonicalSessionsDir(testDir);
if (fs.existsSync(claudeDir)) {
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
if (files.length > 0) {
@@ -3903,7 +3944,7 @@ async function runTests() {
if (
await asyncTest('does not inject empty session file content into context', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-start-empty-file-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
@@ -3919,7 +3960,8 @@ async function runTests() {
});
assert.strictEqual(result.code, 0, 'Should exit 0 with empty session file');
// readFile returns '' (falsy) → the if (content && ...) guard skips injection
assert.ok(!result.stdout.includes('Previous session summary'), 'Should NOT inject empty string into context');
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(!additionalContext.includes('Previous session summary'), 'Should NOT inject empty string into context');
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
@@ -3963,7 +4005,7 @@ async function runTests() {
if (
await asyncTest('summary omits Files Modified and Tools Used when none found', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-notools-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
const testDir = createTestDir();
@@ -4001,7 +4043,7 @@ async function runTests() {
if (
await asyncTest('reports available session aliases on startup', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-start-alias-${Date.now()}`);
fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true });
fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
// Pre-populate the aliases file
@@ -4038,7 +4080,7 @@ async function runTests() {
if (
await asyncTest('parallel compaction runs all append to log without loss', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-compact-par-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
try {
@@ -4073,7 +4115,7 @@ async function runTests() {
const isoHome = path.join(os.tmpdir(), `ecc-start-blocked-${Date.now()}`);
fs.mkdirSync(path.join(isoHome, '.claude'), { recursive: true });
// Block sessions dir creation by placing a file at that path
fs.writeFileSync(path.join(isoHome, '.claude', 'sessions'), 'blocked');
fs.writeFileSync(getCanonicalSessionsDir(isoHome), 'blocked');
try {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
@@ -4136,7 +4178,7 @@ async function runTests() {
if (
await asyncTest('excludes session files older than 7 days', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-start-7day-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
@@ -4159,8 +4201,9 @@ async function runTests() {
});
assert.strictEqual(result.code, 0);
assert.ok(result.stderr.includes('1 recent session'), `Should find 1 recent session (6.9-day included, 8-day excluded), stderr: ${result.stderr}`);
assert.ok(result.stdout.includes('RECENT CONTENT HERE'), 'Should inject the 6.9-day-old session content');
assert.ok(!result.stdout.includes('OLD CONTENT SHOULD NOT APPEAR'), 'Should NOT inject the 8-day-old session content');
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(additionalContext.includes('RECENT CONTENT HERE'), 'Should inject the 6.9-day-old session content');
assert.ok(!additionalContext.includes('OLD CONTENT SHOULD NOT APPEAR'), 'Should NOT inject the 8-day-old session content');
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
@@ -4174,7 +4217,7 @@ async function runTests() {
if (
await asyncTest('injects newest session when multiple recent sessions exist', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-start-multi-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
@@ -4198,7 +4241,8 @@ async function runTests() {
assert.strictEqual(result.code, 0);
assert.ok(result.stderr.includes('2 recent session'), `Should find 2 recent sessions, stderr: ${result.stderr}`);
// Should inject the NEWER session, not the older one
assert.ok(result.stdout.includes('NEWER_CONTEXT_MARKER'), 'Should inject the newest session content');
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(additionalContext.includes('NEWER_CONTEXT_MARKER'), 'Should inject the newest session content');
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
@@ -4305,7 +4349,7 @@ async function runTests() {
return;
}
const isoHome = path.join(os.tmpdir(), `ecc-start-unreadable-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
// Create a session file with real content, then make it unreadable
@@ -4320,7 +4364,8 @@ async function runTests() {
});
assert.strictEqual(result.code, 0, 'Should exit 0 even with unreadable session file');
// readFile returns null for unreadable files → content is null → no injection
assert.ok(!result.stdout.includes('Sensitive session content'), 'Should NOT inject content from unreadable file');
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(!additionalContext.includes('Sensitive session content'), 'Should NOT inject content from unreadable file');
} finally {
try {
fs.chmodSync(sessionFile, 0o644);
@@ -4366,7 +4411,7 @@ async function runTests() {
return;
}
const isoHome = path.join(os.tmpdir(), `ecc-compact-ro-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
// Create a session file then make it read-only
@@ -4407,7 +4452,7 @@ async function runTests() {
if (
await asyncTest('logs warning when existing session file lacks Last Updated field', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-end-nots-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
// Create transcript with a user message so a summary is produced
@@ -4498,7 +4543,7 @@ async function runTests() {
if (
await asyncTest('extracts user messages from role-only format (no type field)', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-role-only-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
const testDir = createTestDir();
@@ -4534,7 +4579,7 @@ async function runTests() {
if (
await asyncTest('logs "Transcript not found" for nonexistent transcript_path', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-notfound-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
const stdinJson = JSON.stringify({ transcript_path: '/tmp/nonexistent-transcript-99999.jsonl' });
@@ -4563,7 +4608,7 @@ async function runTests() {
if (
await asyncTest('extracts tool name and file path from entry.name/entry.input (not tool_name/tool_input)', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-r70-entryname-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
const transcriptPath = path.join(isoHome, 'transcript.jsonl');
@@ -4611,7 +4656,7 @@ async function runTests() {
await asyncTest('shows selection prompt when no package manager preference found (default source)', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-r71-ss-default-${Date.now()}`);
const isoProject = path.join(isoHome, 'project');
fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true });
fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
fs.mkdirSync(isoProject, { recursive: true });
// No package.json, no lock files, no package-manager.json — forces default source
@@ -4758,7 +4803,7 @@ async function runTests() {
if (
await asyncTest('extracts user messages from entries where only message.role is user (not type or role)', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-msgrole-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
const testDir = createTestDir();
@@ -4825,7 +4870,7 @@ async function runTests() {
// session-end.js line 50-55: rawContent is checked for string, then array, else ''
// When content is a number (42), neither branch matches, text = '', message is skipped.
const isoHome = path.join(os.tmpdir(), `ecc-r81-numcontent-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
const transcriptPath = path.join(isoHome, 'transcript.jsonl');
@@ -4874,7 +4919,7 @@ async function runTests() {
if (
await asyncTest('collects tool name from entry with tool_name but non-tool_use type', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-r82-toolname-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
const transcriptPath = path.join(isoHome, 'transcript.jsonl');
@@ -4912,7 +4957,7 @@ async function runTests() {
if (
await asyncTest('preserves file when marker present but regex does not match corrupted template', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-r82-tmpl-${Date.now()}`);
const sessionsDir = path.join(isoHome, '.claude', 'sessions');
const sessionsDir = getCanonicalSessionsDir(isoHome);
fs.mkdirSync(sessionsDir, { recursive: true });
const today = new Date().toISOString().split('T')[0];
@@ -5072,7 +5117,7 @@ Some random content without the expected ### Context to Load section
assert.strictEqual(result.code, 0, 'Should exit 0');
// Read the session file to verify tool names and file paths were extracted
const claudeDir = path.join(testDir, '.claude', 'sessions');
const claudeDir = getCanonicalSessionsDir(testDir);
if (fs.existsSync(claudeDir)) {
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
if (files.length > 0) {
@@ -5193,7 +5238,7 @@ Some random content without the expected ### Context to Load section
});
assert.strictEqual(result.code, 0, 'Should exit 0');
const claudeDir = path.join(testDir, '.claude', 'sessions');
const claudeDir = getCanonicalSessionsDir(testDir);
if (fs.existsSync(claudeDir)) {
const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp'));
if (files.length > 0) {

View File

@@ -79,6 +79,25 @@ function runHook(input, env = {}) {
};
}
function runRawHook(rawInput, env = {}) {
const result = spawnSync('node', [script], {
input: rawInput,
encoding: 'utf8',
env: {
...process.env,
ECC_HOOK_PROFILE: 'standard',
...env
},
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe']
});
return {
code: result.status || 0,
stdout: result.stdout || '',
stderr: result.stderr || ''
};
}
async function runTests() {
console.log('\n=== Testing mcp-health-check.js ===\n');
@@ -95,6 +114,19 @@ async function runTests() {
assert.strictEqual(result.stderr, '', 'Expected no stderr for non-MCP tool');
})) passed++; else failed++;
if (test('blocks truncated MCP hook input by default', () => {
const rawInput = JSON.stringify({ tool_name: 'mcp__flaky__search', tool_input: {} });
const result = runRawHook(rawInput, {
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
ECC_HOOK_INPUT_TRUNCATED: '1',
ECC_HOOK_INPUT_MAX_BYTES: '512'
});
assert.strictEqual(result.code, 2, 'Expected truncated MCP input to block by default');
assert.strictEqual(result.stdout, rawInput, 'Expected raw input passthrough on stdout');
assert.ok(result.stderr.includes('Hook input exceeded 512 bytes'), `Expected size warning, got: ${result.stderr}`);
assert.ok(/blocking search/i.test(result.stderr), `Expected blocking message, got: ${result.stderr}`);
})) passed++; else failed++;
if (await asyncTest('marks healthy command MCP servers and allows the tool call', async () => {
const tempDir = createTempDir();
const configPath = path.join(tempDir, 'claude.json');

View File

@@ -148,6 +148,24 @@ test('analysis temp file is created and cleaned up', () => {
assert.ok(content.includes('rm -f "$prompt_file" "$analysis_file"'), 'Should clean up both prompt and analysis temp files');
});
test('observer-loop uses project-local temp directory for analysis artifacts', () => {
const content = fs.readFileSync(observerLoopPath, 'utf8');
assert.ok(content.includes('observer_tmp_dir="${PROJECT_DIR}/.observer-tmp"'), 'Should keep observer temp files inside the project');
assert.ok(content.includes('mktemp "${observer_tmp_dir}/ecc-observer-analysis.'), 'Analysis temp file should use the project temp dir');
assert.ok(content.includes('mktemp "${observer_tmp_dir}/ecc-observer-prompt.'), 'Prompt temp file should use the project temp dir');
});
test('observer-loop prompt requires direct instinct writes without asking permission', () => {
const content = fs.readFileSync(observerLoopPath, 'utf8');
const heredocStart = content.indexOf('cat > "$prompt_file" <<PROMPT');
const heredocEnd = content.indexOf('\nPROMPT', heredocStart + 1);
assert.ok(heredocStart > 0, 'Should find prompt heredoc start');
assert.ok(heredocEnd > heredocStart, 'Should find prompt heredoc end');
const promptSection = content.substring(heredocStart, heredocEnd);
assert.ok(promptSection.includes('MUST write an instinct file directly'), 'Prompt should require direct file creation');
assert.ok(promptSection.includes('Do NOT ask for permission'), 'Prompt should forbid permission-seeking');
assert.ok(promptSection.includes('write or update the instinct file in this run'), 'Prompt should require same-run writes');
});
test('prompt references analysis_file not full OBSERVATIONS_FILE', () => {
const content = fs.readFileSync(observerLoopPath, 'utf8');
// The prompt heredoc should reference analysis_file for the Read instruction.

View File

@@ -90,6 +90,14 @@ function runHookWithInput(scriptPath, input = {}, env = {}, timeoutMs = 10000) {
});
}
function getSessionStartPayload(stdout) {
if (!stdout.trim()) {
return null;
}
return JSON.parse(stdout);
}
/**
* Run a hook command string exactly as declared in hooks.json.
* Supports wrapped node script commands and shell wrappers.
@@ -249,11 +257,15 @@ async function runTests() {
// ==========================================
console.log('\nHook Output Format:');
if (await asyncTest('hooks output messages to stderr (not stdout)', async () => {
if (await asyncTest('session-start logs diagnostics to stderr and emits structured stdout when context exists', async () => {
const result = await runHookWithInput(path.join(scriptsDir, 'session-start.js'), {});
// Session-start should write info to stderr
assert.ok(result.stderr.length > 0, 'Should have stderr output');
assert.ok(result.stderr.includes('[SessionStart]'), 'Should have [SessionStart] prefix');
if (result.stdout.trim()) {
const payload = getSessionStartPayload(result.stdout);
assert.strictEqual(payload.hookSpecificOutput?.hookEventName, 'SessionStart');
}
})) passed++; else failed++;
if (await asyncTest('PreCompact hook logs to stderr', async () => {

View File

@@ -4,8 +4,9 @@
* Covers the ECC root resolution fallback chain:
* 1. CLAUDE_PLUGIN_ROOT env var
* 2. Standard install (~/.claude/)
* 3. Plugin cache auto-detection
* 4. Fallback to ~/.claude/
* 3. Exact legacy plugin roots under ~/.claude/plugins/
* 4. Plugin cache auto-detection
* 5. Fallback to ~/.claude/
*/
const assert = require('assert');
@@ -39,6 +40,13 @@ function setupStandardInstall(homeDir) {
return claudeDir;
}
function setupLegacyPluginInstall(homeDir, segments) {
const legacyDir = path.join(homeDir, '.claude', 'plugins', ...segments);
const scriptDir = path.join(legacyDir, 'scripts', 'lib');
fs.mkdirSync(scriptDir, { recursive: true });
fs.writeFileSync(path.join(scriptDir, 'utils.js'), '// stub');
return legacyDir;
}
function setupPluginCache(homeDir, orgName, version) {
const cacheDir = path.join(
homeDir, '.claude', 'plugins', 'cache',
@@ -103,6 +111,50 @@ function runTests() {
}
})) passed++; else failed++;
if (test('finds exact legacy plugin install at ~/.claude/plugins/everything-claude-code', () => {
const homeDir = createTempDir();
try {
const expected = setupLegacyPluginInstall(homeDir, ['everything-claude-code']);
const result = resolveEccRoot({ envRoot: '', homeDir });
assert.strictEqual(result, expected);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('finds exact legacy plugin install at ~/.claude/plugins/everything-claude-code@everything-claude-code', () => {
const homeDir = createTempDir();
try {
const expected = setupLegacyPluginInstall(homeDir, ['everything-claude-code@everything-claude-code']);
const result = resolveEccRoot({ envRoot: '', homeDir });
assert.strictEqual(result, expected);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('finds marketplace legacy plugin install at ~/.claude/plugins/marketplace/everything-claude-code', () => {
const homeDir = createTempDir();
try {
const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'everything-claude-code']);
const result = resolveEccRoot({ envRoot: '', homeDir });
assert.strictEqual(result, expected);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('prefers exact legacy plugin install over plugin cache', () => {
const homeDir = createTempDir();
try {
const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'everything-claude-code']);
setupPluginCache(homeDir, 'everything-claude-code', '1.8.0');
const result = resolveEccRoot({ envRoot: '', homeDir });
assert.strictEqual(result, expected);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
// ─── Plugin Cache Auto-Detection ───
if (test('discovers plugin root from cache directory', () => {
@@ -207,6 +259,22 @@ function runTests() {
assert.strictEqual(result, '/inline/test/root');
})) passed++; else failed++;
if (test('INLINE_RESOLVE discovers exact legacy plugin root when env var is unset', () => {
const homeDir = createTempDir();
try {
const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'everything-claude-code']);
const { execFileSync } = require('child_process');
const result = execFileSync('node', [
'-e', `console.log(${INLINE_RESOLVE})`,
], {
env: { PATH: process.env.PATH, HOME: homeDir, USERPROFILE: homeDir },
encoding: 'utf8',
}).trim();
assert.strictEqual(result, expected);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('INLINE_RESOLVE discovers plugin cache when env var is unset', () => {
const homeDir = createTempDir();
try {

View File

@@ -990,7 +990,7 @@ src/main.ts
assert.ok(result.endsWith(filename), `Path should end with filename, got: ${result}`);
// Since HOME is overridden, sessions dir should be under tmpHome
assert.ok(result.includes('.claude'), 'Path should include .claude directory');
assert.ok(result.includes('sessions'), 'Path should include sessions directory');
assert.ok(result.includes('session-data'), 'Path should use canonical session-data directory');
})) passed++; else failed++;
// ── Round 66: getSessionById noIdMatch path (date-only string for old format) ──
@@ -1629,18 +1629,13 @@ src/main.ts
// best-effort
}
// ── Round 98: parseSessionFilename with null input throws TypeError ──
console.log('\nRound 98: parseSessionFilename (null input — crashes at line 30):');
// ── Round 98: parseSessionFilename with null input returns null ──
console.log('\nRound 98: parseSessionFilename (null input is safely rejected):');
if (test('parseSessionFilename(null) throws TypeError because null has no .match()', () => {
// session-manager.js line 30: `filename.match(SESSION_FILENAME_REGEX)`
// When filename is null, null.match() throws TypeError.
// Function lacks a type guard like `if (!filename || typeof filename !== 'string')`.
assert.throws(
() => sessionManager.parseSessionFilename(null),
{ name: 'TypeError' },
'null.match() should throw TypeError (no type guard on filename parameter)'
);
if (test('parseSessionFilename(null) returns null instead of throwing', () => {
assert.strictEqual(sessionManager.parseSessionFilename(null), null);
assert.strictEqual(sessionManager.parseSessionFilename(undefined), null);
assert.strictEqual(sessionManager.parseSessionFilename(123), null);
})) passed++; else failed++;
// ── Round 99: writeSessionContent with null path returns false (error caught) ──

View File

@@ -7,6 +7,7 @@
const assert = require('assert');
const path = require('path');
const fs = require('fs');
const { spawnSync } = require('child_process');
// Import the module
const utils = require('../../scripts/lib/utils');
@@ -68,7 +69,13 @@ function runTests() {
const sessionsDir = utils.getSessionsDir();
const claudeDir = utils.getClaudeDir();
assert.ok(sessionsDir.startsWith(claudeDir), 'Sessions should be under Claude dir');
assert.ok(sessionsDir.includes('sessions'), 'Should contain sessions');
assert.ok(sessionsDir.endsWith(path.join('.claude', 'session-data')) || sessionsDir.endsWith('/.claude/session-data'), 'Should use canonical session-data directory');
})) passed++; else failed++;
if (test('getSessionSearchDirs includes canonical and legacy paths', () => {
const searchDirs = utils.getSessionSearchDirs();
assert.ok(searchDirs.includes(utils.getSessionsDir()), 'Should include canonical session dir');
assert.ok(searchDirs.includes(utils.getLegacySessionsDir()), 'Should include legacy session dir');
})) passed++; else failed++;
if (test('getTempDir returns valid temp directory', () => {
@@ -118,17 +125,77 @@ function runTests() {
assert.ok(name && name.length > 0);
})) passed++; else failed++;
// sanitizeSessionId tests
console.log('\nsanitizeSessionId:');
if (test('sanitizeSessionId strips leading dots', () => {
assert.strictEqual(utils.sanitizeSessionId('.claude'), 'claude');
})) passed++; else failed++;
if (test('sanitizeSessionId replaces dots and spaces', () => {
assert.strictEqual(utils.sanitizeSessionId('my.project'), 'my-project');
assert.strictEqual(utils.sanitizeSessionId('my project'), 'my-project');
})) passed++; else failed++;
if (test('sanitizeSessionId replaces special chars and collapses runs', () => {
assert.strictEqual(utils.sanitizeSessionId('project@v2'), 'project-v2');
assert.strictEqual(utils.sanitizeSessionId('a...b'), 'a-b');
})) passed++; else failed++;
if (test('sanitizeSessionId preserves valid chars', () => {
assert.strictEqual(utils.sanitizeSessionId('my-project_123'), 'my-project_123');
})) passed++; else failed++;
if (test('sanitizeSessionId returns null for empty or punctuation-only values', () => {
assert.strictEqual(utils.sanitizeSessionId(''), null);
assert.strictEqual(utils.sanitizeSessionId(null), null);
assert.strictEqual(utils.sanitizeSessionId(undefined), null);
assert.strictEqual(utils.sanitizeSessionId('...'), null);
assert.strictEqual(utils.sanitizeSessionId('…'), null);
})) passed++; else failed++;
if (test('sanitizeSessionId returns stable hashes for non-ASCII values', () => {
const chinese = utils.sanitizeSessionId('我的项目');
const cyrillic = utils.sanitizeSessionId('проект');
const emoji = utils.sanitizeSessionId('🚀🎉');
assert.ok(/^[a-f0-9]{8}$/.test(chinese), `Expected 8-char hash, got: ${chinese}`);
assert.ok(/^[a-f0-9]{8}$/.test(cyrillic), `Expected 8-char hash, got: ${cyrillic}`);
assert.ok(/^[a-f0-9]{8}$/.test(emoji), `Expected 8-char hash, got: ${emoji}`);
assert.notStrictEqual(chinese, cyrillic);
assert.notStrictEqual(chinese, emoji);
assert.strictEqual(utils.sanitizeSessionId('日本語プロジェクト'), utils.sanitizeSessionId('日本語プロジェクト'));
})) passed++; else failed++;
if (test('sanitizeSessionId disambiguates mixed-script names from pure ASCII', () => {
const mixed = utils.sanitizeSessionId('我的app');
const mixedTwo = utils.sanitizeSessionId('他的app');
const pure = utils.sanitizeSessionId('app');
assert.strictEqual(pure, 'app');
assert.ok(mixed.startsWith('app-'), `Expected mixed-script prefix, got: ${mixed}`);
assert.notStrictEqual(mixed, pure);
assert.notStrictEqual(mixed, mixedTwo);
})) passed++; else failed++;
if (test('sanitizeSessionId is idempotent', () => {
for (const input of ['.claude', 'my.project', 'project@v2', 'a...b', 'my-project_123']) {
const once = utils.sanitizeSessionId(input);
const twice = utils.sanitizeSessionId(once);
assert.strictEqual(once, twice, `Expected idempotent result for ${input}`);
}
})) passed++; else failed++;
// Session ID tests
console.log('\nSession ID Functions:');
if (test('getSessionIdShort falls back to project name', () => {
if (test('getSessionIdShort falls back to sanitized project name', () => {
const original = process.env.CLAUDE_SESSION_ID;
delete process.env.CLAUDE_SESSION_ID;
try {
const shortId = utils.getSessionIdShort();
assert.strictEqual(shortId, utils.getProjectName());
assert.strictEqual(shortId, utils.sanitizeSessionId(utils.getProjectName()));
} finally {
if (original) process.env.CLAUDE_SESSION_ID = original;
if (original !== undefined) process.env.CLAUDE_SESSION_ID = original;
else delete process.env.CLAUDE_SESSION_ID;
}
})) passed++; else failed++;
@@ -154,6 +221,28 @@ function runTests() {
}
})) passed++; else failed++;
if (test('getSessionIdShort sanitizes explicit fallback parameter', () => {
if (process.platform === 'win32') {
console.log(' (skipped — root CWD differs on Windows)');
return true;
}
const utilsPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'utils.js');
const script = `
const utils = require('${utilsPath.replace(/'/g, "\\'")}');
process.stdout.write(utils.getSessionIdShort('my.fallback'));
`;
const result = spawnSync('node', ['-e', script], {
encoding: 'utf8',
cwd: '/',
env: { ...process.env, CLAUDE_SESSION_ID: '' },
timeout: 10000
});
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`);
assert.strictEqual(result.stdout, 'my-fallback');
})) passed++; else failed++;
// File operations tests
console.log('\nFile Operations:');
@@ -1415,25 +1504,26 @@ function runTests() {
// ── Round 97: getSessionIdShort with whitespace-only CLAUDE_SESSION_ID ──
console.log('\nRound 97: getSessionIdShort (whitespace-only session ID):');
if (test('getSessionIdShort returns whitespace when CLAUDE_SESSION_ID is all spaces', () => {
// utils.js line 116: if (sessionId && sessionId.length > 0) — ' ' is truthy
// and has length > 0, so it passes the check instead of falling back.
const original = process.env.CLAUDE_SESSION_ID;
try {
process.env.CLAUDE_SESSION_ID = ' '; // 10 spaces
const result = utils.getSessionIdShort('fallback');
// slice(-8) on 10 spaces returns 8 spaces — not the expected fallback
assert.strictEqual(result, ' ',
'Whitespace-only ID should return 8 trailing spaces (no trim check)');
assert.strictEqual(result.trim().length, 0,
'Result should be entirely whitespace (demonstrating the missing trim)');
} finally {
if (original !== undefined) {
process.env.CLAUDE_SESSION_ID = original;
} else {
delete process.env.CLAUDE_SESSION_ID;
}
if (test('getSessionIdShort sanitizes whitespace-only CLAUDE_SESSION_ID to fallback', () => {
if (process.platform === 'win32') {
console.log(' (skipped — root CWD differs on Windows)');
return true;
}
const utilsPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'utils.js');
const script = `
const utils = require('${utilsPath.replace(/'/g, "\\'")}');
process.stdout.write(utils.getSessionIdShort('fallback'));
`;
const result = spawnSync('node', ['-e', script], {
encoding: 'utf8',
cwd: '/',
env: { ...process.env, CLAUDE_SESSION_ID: ' ' },
timeout: 10000
});
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`);
assert.strictEqual(result.stdout, 'fallback');
})) passed++; else failed++;
// ── Round 97: countInFile with same RegExp object called twice (lastIndex reuse) ──

View File

@@ -0,0 +1,84 @@
/**
* Tests for Codex shell helpers.
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.join(__dirname, '..', '..');
const installScript = path.join(repoRoot, 'scripts', 'codex', 'install-global-git-hooks.sh');
const installSource = fs.readFileSync(installScript, 'utf8');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanup(dirPath) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
function runBash(scriptPath, args = [], env = {}, cwd = repoRoot) {
return spawnSync('bash', [scriptPath, ...args], {
cwd,
env: {
...process.env,
...env
},
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
});
}
let passed = 0;
let failed = 0;
if (
test('install-global-git-hooks.sh does not use eval and executes argv directly', () => {
assert.ok(!installSource.includes('eval "$*"'), 'Expected installer to avoid eval');
assert.ok(installSource.includes(' "$@"'), 'Expected installer to execute argv directly');
assert.ok(installSource.includes(`printf ' %q' "$@"`), 'Expected dry-run logging to shell-escape argv');
})
)
passed++;
else failed++;
if (
test('install-global-git-hooks.sh handles quoted hook paths without shell injection', () => {
const homeDir = createTempDir('codex-hooks-home-');
const weirdHooksDir = path.join(homeDir, 'git-hooks "quoted"');
try {
const result = runBash(installScript, [], {
HOME: homeDir,
ECC_GLOBAL_HOOKS_DIR: weirdHooksDir
});
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
assert.ok(fs.existsSync(path.join(weirdHooksDir, 'pre-commit')));
assert.ok(fs.existsSync(path.join(weirdHooksDir, 'pre-push')));
} finally {
cleanup(homeDir);
}
})
)
passed++;
else failed++;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);

View File

@@ -94,6 +94,7 @@ function runTests() {
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'typescript', 'testing.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'lib', 'utils.js')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'coding-standards', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json')));
@@ -132,6 +133,7 @@ function runTests() {
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks', 'session-start.js')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'scripts', 'lib', 'utils.js')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'tdd-workflow', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'coding-standards', 'SKILL.md')));
@@ -239,6 +241,7 @@ function runTests() {
assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'lib', 'session-manager.js')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json')));
const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json'));

View File

@@ -0,0 +1,52 @@
/**
* Source-level tests for scripts/sync-ecc-to-codex.sh
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'sync-ecc-to-codex.sh');
const source = fs.readFileSync(scriptPath, 'utf8');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing sync-ecc-to-codex.sh ===\n');
let passed = 0;
let failed = 0;
if (test('run_or_echo does not use eval', () => {
assert.ok(!source.includes('eval "$@"'), 'run_or_echo should not execute through eval');
})) passed++; else failed++;
if (test('run_or_echo executes argv directly', () => {
assert.ok(source.includes(' "$@"'), 'run_or_echo should execute the argv vector directly');
})) passed++; else failed++;
if (test('dry-run output shell-escapes argv', () => {
assert.ok(source.includes(`printf ' %q' "$@"`), 'Dry-run mode should print shell-escaped argv');
})) passed++; else failed++;
if (test('filesystem-changing calls use argv-form run_or_echo invocations', () => {
assert.ok(source.includes('run_or_echo mkdir -p "$BACKUP_DIR"'), 'mkdir should use argv form');
assert.ok(source.includes('run_or_echo rm -rf "$dest"'), 'rm should use argv form');
assert.ok(source.includes('run_or_echo cp -R "$skill_dir" "$dest"'), 'recursive copy should use argv form');
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();