mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
1393 lines
59 KiB
JavaScript
1393 lines
59 KiB
JavaScript
const { describe, it } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const { spawnSync } = require('child_process');
|
|
const { canCreateSymlinks } = require('./helpers/symlink');
|
|
|
|
// Symlink-rejection tests need to plant a real symlink before exercising
|
|
// the code under test. On Windows without admin / developer-mode that
|
|
// fails in setup with EPERM, so we route those tests through `symlinkIt`
|
|
// which becomes a no-op skip when symlink creation isn't available.
|
|
const symlinkIt = canCreateSymlinks() ? it : it.skip;
|
|
|
|
const hookAdapter = require('../src/adapters/hookAdapter');
|
|
const cursorAdapter = require('../src/adapters/cursor');
|
|
const claudeAdapter = require('../src/adapters/claudeCode');
|
|
const codexAdapter = require('../src/adapters/codex');
|
|
|
|
function makeTmpDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-hooks-test-'));
|
|
}
|
|
|
|
function cleanup(dir) {
|
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
}
|
|
|
|
// Run `fn` with a controlled set of host-detection env vars. All known signal
|
|
// vars are cleared first (so directory-based detection is exercised even when
|
|
// the suite runs from inside a Claude Code / Cursor session, #590), then the
|
|
// `patch` values are applied; everything is restored afterwards.
|
|
function withEnv(patch, fn) {
|
|
const keys = [
|
|
'CLAUDECODE',
|
|
'CLAUDE_CODE_ENTRYPOINT',
|
|
'CLAUDE_PROJECT_DIR',
|
|
'CURSOR_TRACE_ID',
|
|
'CURSOR_SESSION_ID',
|
|
'CURSOR_PROJECT_DIR',
|
|
'CURSOR_AGENT',
|
|
'CODEX_THREAD_ID',
|
|
'CODEX_SHELL',
|
|
'CODEX_CI',
|
|
'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
|
|
'TERM_PROGRAM',
|
|
...Object.keys(patch),
|
|
];
|
|
const oldEnv = {};
|
|
for (const key of keys) {
|
|
oldEnv[key] = process.env[key];
|
|
delete process.env[key];
|
|
}
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
if (value !== undefined) process.env[key] = value;
|
|
}
|
|
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
for (const key of keys) {
|
|
if (oldEnv[key] === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = oldEnv[key];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function withHome(home, fn) {
|
|
const originalHome = os.homedir;
|
|
os.homedir = () => home;
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
os.homedir = originalHome;
|
|
}
|
|
}
|
|
|
|
// -- hookAdapter --
|
|
|
|
describe('hookAdapter', () => {
|
|
describe('detectPlatform', () => {
|
|
it('detects cursor from .cursor directory', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, '.cursor'), { recursive: true });
|
|
// Clear host env signals so directory detection is exercised even when
|
|
// the suite runs from inside a Claude Code / Cursor session (#590).
|
|
withEnv({}, () => {
|
|
assert.equal(hookAdapter.detectPlatform(tmp), 'cursor');
|
|
});
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('detects claude-code from .claude directory', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, '.claude'), { recursive: true });
|
|
withEnv({}, () => {
|
|
assert.equal(hookAdapter.detectPlatform(tmp), 'claude-code');
|
|
});
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('detects codex from .codex directory', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, '.codex'), { recursive: true });
|
|
withEnv({}, () => {
|
|
assert.equal(hookAdapter.detectPlatform(tmp), 'codex');
|
|
});
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('prefers claude-code environment over home platform directories (#590)', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const cwd = path.join(tmp, 'project');
|
|
const home = path.join(tmp, 'home');
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
fs.mkdirSync(path.join(home, '.cursor'), { recursive: true });
|
|
fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
|
|
withHome(home, () => withEnv({ CLAUDECODE: '1' }, () => {
|
|
assert.equal(hookAdapter.detectPlatform(cwd), 'claude-code');
|
|
}));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('prefers cursor environment over directory detection (#590)', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const cwd = path.join(tmp, 'project');
|
|
const home = path.join(tmp, 'home');
|
|
fs.mkdirSync(path.join(cwd, '.claude'), { recursive: true });
|
|
fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
|
|
withHome(home, () => withEnv({ CURSOR_TRACE_ID: 'trace-1' }, () => {
|
|
assert.equal(hookAdapter.detectPlatform(cwd), 'cursor');
|
|
}));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('prefers CLAUDECODE over cursor environment signals', () => {
|
|
withEnv({
|
|
CLAUDECODE: '1',
|
|
CURSOR_TRACE_ID: 'trace-1',
|
|
TERM_PROGRAM: 'cursor',
|
|
}, () => {
|
|
assert.equal(hookAdapter.detectPlatformFromEnv(), 'claude-code');
|
|
});
|
|
});
|
|
|
|
it('prefers CLAUDE_CODE_ENTRYPOINT over cursor environment signals', () => {
|
|
withEnv({
|
|
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
|
CURSOR_SESSION_ID: 'session-1',
|
|
CURSOR_PROJECT_DIR: '/tmp/cursor-project',
|
|
}, () => {
|
|
assert.equal(hookAdapter.detectPlatformFromEnv(), 'claude-code');
|
|
});
|
|
});
|
|
|
|
it('prefers cursor strong signals over CLAUDE_PROJECT_DIR compat alias', () => {
|
|
withEnv({
|
|
CURSOR_TRACE_ID: 'trace-1',
|
|
TERM_PROGRAM: 'cursor',
|
|
CLAUDE_PROJECT_DIR: '/tmp/cursor-compat-alias',
|
|
}, () => {
|
|
assert.equal(hookAdapter.detectPlatformFromEnv(), 'cursor');
|
|
});
|
|
});
|
|
|
|
it('detects CURSOR_AGENT as a cursor strong signal before CLAUDE_PROJECT_DIR', () => {
|
|
withEnv({
|
|
CURSOR_AGENT: '1',
|
|
CLAUDE_PROJECT_DIR: '/tmp/cursor-compat-alias',
|
|
}, () => {
|
|
assert.equal(hookAdapter.detectPlatformFromEnv(), 'cursor');
|
|
});
|
|
});
|
|
|
|
it('prefers explicit cursor runtime signals over codex environment signals', () => {
|
|
withEnv({
|
|
CURSOR_AGENT: '1',
|
|
CODEX_THREAD_ID: 'codex-thread-1',
|
|
}, () => {
|
|
assert.equal(hookAdapter.detectPlatformFromEnv(), 'cursor');
|
|
});
|
|
});
|
|
|
|
it('prefers explicit claude-code runtime signals over codex environment signals', () => {
|
|
withEnv({
|
|
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
|
CODEX_THREAD_ID: 'codex-thread-1',
|
|
}, () => {
|
|
assert.equal(hookAdapter.detectPlatformFromEnv(), 'claude-code');
|
|
});
|
|
});
|
|
|
|
it('prefers codex environment signals over directory fallback', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const cwd = path.join(tmp, 'project');
|
|
fs.mkdirSync(path.join(cwd, '.cursor'), { recursive: true });
|
|
withEnv({
|
|
CODEX_THREAD_ID: 'codex-thread-1',
|
|
CODEX_SHELL: 'zsh',
|
|
CODEX_CI: '1',
|
|
CODEX_INTERNAL_ORIGINATOR_OVERRIDE: 'codex',
|
|
}, () => {
|
|
assert.equal(hookAdapter.detectPlatform(cwd), 'codex');
|
|
});
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('keeps fallback directory detection when no host environment is present (#590)', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const cwd = path.join(tmp, 'project');
|
|
const home = path.join(tmp, 'home');
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
fs.mkdirSync(path.join(home, '.cursor'), { recursive: true });
|
|
withHome(home, () => withEnv({}, () => {
|
|
assert.equal(hookAdapter.detectPlatform(cwd), 'cursor');
|
|
}));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('keeps cwd detector precedence over home fallback without host env (#590)', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const cwd = path.join(tmp, 'project');
|
|
const home = path.join(tmp, 'home');
|
|
fs.mkdirSync(path.join(cwd, '.claude'), { recursive: true });
|
|
fs.mkdirSync(path.join(home, '.cursor'), { recursive: true });
|
|
withHome(home, () => withEnv({}, () => {
|
|
assert.equal(hookAdapter.detectPlatform(cwd), 'claude-code');
|
|
}));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('returns null for unknown platform when no fallback dirs exist', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
// detectPlatform checks cwd first, then homedir fallback.
|
|
// On machines with ~/.cursor, it will find cursor via fallback.
|
|
// This test only asserts that the cwd itself yields nothing.
|
|
const result = hookAdapter.detectPlatform(tmp);
|
|
// If homedir has a platform dir, the function returns that.
|
|
// We just verify the function doesn't crash and returns a valid result.
|
|
assert.ok(result === null || typeof result === 'string');
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
});
|
|
|
|
describe('deepMerge', () => {
|
|
it('merges nested objects', () => {
|
|
const a = { x: { a: 1 }, y: 2 };
|
|
const b = { x: { b: 3 }, z: 4 };
|
|
const result = hookAdapter.deepMerge(a, b);
|
|
assert.deepEqual(result, { x: { a: 1, b: 3 }, y: 2, z: 4 });
|
|
});
|
|
|
|
it('overwrites arrays', () => {
|
|
const a = { arr: [1, 2] };
|
|
const b = { arr: [3, 4, 5] };
|
|
const result = hookAdapter.deepMerge(a, b);
|
|
assert.deepEqual(result.arr, [3, 4, 5]);
|
|
});
|
|
});
|
|
|
|
describe('mergeJsonFile', () => {
|
|
it('creates file if not exists', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const filePath = path.join(tmp, 'test.json');
|
|
hookAdapter.mergeJsonFile(filePath, { hooks: { a: 1 } });
|
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
assert.equal(data.hooks.a, 1);
|
|
assert.equal(data._evolver_managed, true);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('merges into existing file', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const filePath = path.join(tmp, 'test.json');
|
|
fs.writeFileSync(filePath, JSON.stringify({ existing: true, hooks: { old: 1 } }));
|
|
hookAdapter.mergeJsonFile(filePath, { hooks: { new: 2 } });
|
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
assert.equal(data.existing, true);
|
|
assert.equal(data.hooks.old, 1);
|
|
assert.equal(data.hooks.new, 2);
|
|
assert.equal(data._evolver_managed, true);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
});
|
|
|
|
describe('appendSectionToFile', () => {
|
|
it('appends section to new file', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const filePath = path.join(tmp, 'README.md');
|
|
const result = hookAdapter.appendSectionToFile(filePath, '<!-- marker -->', '<!-- marker -->\nHello');
|
|
assert.equal(result, true);
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
assert.ok(content.includes('<!-- marker -->'));
|
|
assert.ok(content.includes('Hello'));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('does not duplicate if marker exists', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const filePath = path.join(tmp, 'README.md');
|
|
fs.writeFileSync(filePath, '<!-- marker -->\nExisting');
|
|
const result = hookAdapter.appendSectionToFile(filePath, '<!-- marker -->', '<!-- marker -->\nDuplicate');
|
|
assert.equal(result, false);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
});
|
|
|
|
describe('copyHookScripts', () => {
|
|
it('copies scripts to destination', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const destDir = path.join(tmp, 'hooks');
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
const copied = hookAdapter.copyHookScripts(destDir, path.join(evolverRoot, 'src', 'adapters'));
|
|
// 4 hook entry points (session-start, signal-detect, session-end,
|
|
// task-recall) + 3 helpers (`_runtimePaths.js`, `_memoryFiltering.js`,
|
|
// `_lockPaths.js`) required by them via `require('./...')`. Helper
|
|
// list is verified separately by the "#547 — setup-hooks copies every
|
|
// helper required by the entry-point scripts" suite, which scans the
|
|
// actual `require` statements; this assertion is just a quick smoke
|
|
// test on count.
|
|
assert.equal(copied.length, 7);
|
|
for (const f of copied) {
|
|
assert.ok(fs.existsSync(f));
|
|
}
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('includes _runtimePaths.js so copied session hooks can require it (PR #94)', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const destDir = path.join(tmp, 'hooks');
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
hookAdapter.copyHookScripts(destDir, path.join(evolverRoot, 'src', 'adapters'));
|
|
assert.ok(fs.existsSync(path.join(destDir, '_runtimePaths.js')),
|
|
'_runtimePaths.js must ship alongside session-start/end or both crash with MODULE_NOT_FOUND');
|
|
|
|
// End-to-end: actually run the copied script. If `_runtimePaths.js`
|
|
// is missing the require() at top of file would fail with
|
|
// MODULE_NOT_FOUND and exit non-zero.
|
|
const result = spawnSync('node', [path.join(destDir, 'evolver-session-start.js')], {
|
|
input: '{}', encoding: 'utf8', timeout: 5000,
|
|
});
|
|
assert.equal(result.status, 0,
|
|
`copied evolver-session-start.js must run without error. stderr=${result.stderr}`);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('copied evolver-task-recall.js runs fail-open (exit 0 + parseable {}) with mode=off', () => {
|
|
// The require-scanner suite only verifies same-dir `./_helper` requires;
|
|
// evolver-task-recall.js resolves its core via an ABSOLUTE require
|
|
// (findEvolverRoot()+src/gep/recallInject.js), which that scanner cannot
|
|
// cover. This e2e proves the copied hook (a) parses its stdin, (b) never
|
|
// crashes, and (c) emits exactly one JSON object — the hook contract.
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const destDir = path.join(tmp, 'hooks');
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
hookAdapter.copyHookScripts(destDir, path.join(evolverRoot, 'src', 'adapters'));
|
|
assert.ok(fs.existsSync(path.join(destDir, 'evolver-task-recall.js')),
|
|
'evolver-task-recall.js must be copied by setup-hooks');
|
|
|
|
const result = spawnSync('node', [path.join(destDir, 'evolver-task-recall.js')], {
|
|
input: JSON.stringify({ prompt: 'add retry with backoff to the http client', session_id: 'sess-test' }),
|
|
encoding: 'utf8',
|
|
timeout: 8000,
|
|
// mode=off: must finish {} without parsing/sending the prompt.
|
|
env: { ...process.env, EVOLVER_RECALL_MODE: 'off', A2A_HUB_URL: '' },
|
|
});
|
|
assert.equal(result.status, 0,
|
|
`copied evolver-task-recall.js must exit 0 (fail-open). stderr=${result.stderr}`);
|
|
const parsed = JSON.parse(result.stdout || '{}');
|
|
assert.deepEqual(parsed, {}, 'mode=off must inject nothing (empty object)');
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
});
|
|
|
|
describe('removeHookScripts', () => {
|
|
it('removes evolver scripts', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const hooksDir = path.join(tmp, 'hooks');
|
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
fs.writeFileSync(path.join(hooksDir, '_runtimePaths.js'), '');
|
|
fs.writeFileSync(path.join(hooksDir, 'evolver-session-start.js'), '');
|
|
fs.writeFileSync(path.join(hooksDir, 'evolver-signal-detect.js'), '');
|
|
fs.writeFileSync(path.join(hooksDir, 'evolver-session-end.js'), '');
|
|
fs.writeFileSync(path.join(hooksDir, 'user-custom.js'), '');
|
|
const removed = hookAdapter.removeHookScripts(hooksDir);
|
|
assert.equal(removed, 4);
|
|
assert.ok(fs.existsSync(path.join(hooksDir, 'user-custom.js')));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
});
|
|
|
|
describe('mergeWithHooksUnion (#539)', () => {
|
|
it('preserves user-installed hooks under same event when adding evolver hooks', () => {
|
|
const userHooks = {
|
|
hooks: {
|
|
Stop: [
|
|
{ hooks: [{ type: 'command', command: 'node user-tool.js' }] },
|
|
],
|
|
SessionStart: [
|
|
{ hooks: [{ type: 'command', command: 'node user-init.js' }] },
|
|
],
|
|
},
|
|
};
|
|
const evolverPatch = {
|
|
hooks: {
|
|
Stop: [
|
|
{ hooks: [{ type: 'command', command: 'node .claude/hooks/evolver-session-end.js' }] },
|
|
],
|
|
SessionStart: [
|
|
{ hooks: [{ type: 'command', command: 'node .claude/hooks/evolver-session-start.js' }] },
|
|
],
|
|
},
|
|
};
|
|
const merged = hookAdapter.mergeWithHooksUnion(userHooks, evolverPatch);
|
|
assert.equal(merged.hooks.Stop.length, 2, 'Stop must contain both user and evolver entries');
|
|
assert.equal(merged.hooks.SessionStart.length, 2, 'SessionStart must contain both');
|
|
const stopCmds = merged.hooks.Stop.flatMap(m => (m.hooks || []).map(h => h.command));
|
|
assert.ok(stopCmds.includes('node user-tool.js'), 'user Stop hook must be preserved');
|
|
assert.ok(stopCmds.some(c => c.includes('evolver-session-end')), 'evolver Stop hook must be added');
|
|
});
|
|
|
|
it('refreshes (does not duplicate) evolver-owned entries on reinstall', () => {
|
|
const previousInstall = {
|
|
hooks: {
|
|
Stop: [
|
|
{ hooks: [{ type: 'command', command: 'node OLD/evolver-session-end.js' }] },
|
|
],
|
|
SessionStart: [
|
|
{ hooks: [{ type: 'command', command: 'bash OLD/evolver-daemon-start.sh' }] },
|
|
],
|
|
},
|
|
};
|
|
const newPatch = {
|
|
hooks: {
|
|
Stop: [
|
|
{ hooks: [{ type: 'command', command: 'node NEW/evolver-session-end.js' }] },
|
|
],
|
|
SessionStart: [
|
|
{ hooks: [{ type: 'command', command: 'node NEW/evolver-session-start.js' }] },
|
|
],
|
|
},
|
|
};
|
|
const merged = hookAdapter.mergeWithHooksUnion(previousInstall, newPatch);
|
|
assert.equal(merged.hooks.Stop.length, 1, 'evolver entry must be refreshed, not duplicated');
|
|
assert.equal(merged.hooks.SessionStart.length, 1, 'legacy daemon hook must be refreshed, not preserved');
|
|
const cmds = merged.hooks.Stop.flatMap(m => (m.hooks || []).map(h => h.command));
|
|
assert.ok(cmds[0].includes('NEW/'));
|
|
assert.ok(!cmds.some(c => c.includes('OLD/')));
|
|
const sessionCmds = merged.hooks.SessionStart.flatMap(m => (m.hooks || []).map(h => h.command));
|
|
assert.ok(sessionCmds[0].includes('evolver-session-start'));
|
|
assert.ok(!sessionCmds.some(c => c.includes('evolver-daemon-start')));
|
|
});
|
|
|
|
it('preserves user hooks sharing a matcher with refreshed evolver hooks', () => {
|
|
const previousInstall = {
|
|
hooks: {
|
|
Stop: [
|
|
{
|
|
hooks: [
|
|
{ type: 'command', command: 'node OLD/evolver-session-end.js' },
|
|
{ type: 'command', command: 'node user-cleanup.js' },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const newPatch = {
|
|
hooks: {
|
|
Stop: [
|
|
{
|
|
hooks: [{ type: 'command', command: 'node NEW/evolver-session-end.js' }],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const merged = hookAdapter.mergeWithHooksUnion(previousInstall, newPatch);
|
|
const cmds = merged.hooks.Stop.flatMap(m => (m.hooks || []).map(h => h.command));
|
|
assert.ok(cmds.includes('node user-cleanup.js'));
|
|
assert.ok(cmds.includes('node NEW/evolver-session-end.js'));
|
|
assert.ok(!cmds.some(c => c.includes('OLD/evolver-session-end')));
|
|
});
|
|
|
|
it('handles flat (Codex) command shape', () => {
|
|
const userHooks = {
|
|
hooks: {
|
|
Stop: [{ type: 'command', command: 'node user-tool.js' }],
|
|
},
|
|
};
|
|
const evolverPatch = {
|
|
hooks: {
|
|
Stop: [{ type: 'command', command: 'node .codex/hooks/evolver-session-end.js' }],
|
|
},
|
|
};
|
|
const merged = hookAdapter.mergeWithHooksUnion(userHooks, evolverPatch);
|
|
assert.equal(merged.hooks.Stop.length, 2);
|
|
assert.ok(merged.hooks.Stop.some(h => h.command === 'node user-tool.js'));
|
|
assert.ok(merged.hooks.Stop.some(h => h.command.includes('evolver-session-end')));
|
|
});
|
|
});
|
|
|
|
describe('removeMarkedSection (#538)', () => {
|
|
it('removes evolver section without consuming user H2 below', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const file = path.join(tmp, 'AGENTS.md');
|
|
const userBefore = '# Project\n\nIntro paragraph.\n\n';
|
|
const evolverSection =
|
|
'<!-- evolver-evolution-memory -->\n' +
|
|
'## Evolution Memory (Evolver)\n\n' +
|
|
'Body 1\nBody 2\n\n';
|
|
const userAfter = '## Other Section\n\nUser content here.\n';
|
|
fs.writeFileSync(file, userBefore + evolverSection + userAfter);
|
|
|
|
const ok = hookAdapter.removeMarkedSection(file, '<!-- evolver-evolution-memory -->');
|
|
assert.equal(ok, true);
|
|
|
|
const next = fs.readFileSync(file, 'utf8');
|
|
assert.ok(!next.includes('evolver-evolution-memory'), 'marker must be gone');
|
|
assert.ok(!next.includes('## Evolution Memory'), 'evolver heading must be gone');
|
|
assert.ok(next.includes('## Other Section'), 'user section must remain');
|
|
assert.ok(next.includes('User content here.'), 'user content must remain');
|
|
assert.ok(next.includes('Intro paragraph'), 'pre-marker content must remain');
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('handles trailing-only evolver section (no user content after)', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const file = path.join(tmp, 'AGENTS.md');
|
|
fs.writeFileSync(file,
|
|
'# Project\n\nIntro\n\n' +
|
|
'<!-- evolver-evolution-memory -->\n' +
|
|
'## Evolution Memory (Evolver)\n\nBody\n');
|
|
hookAdapter.removeMarkedSection(file, '<!-- evolver-evolution-memory -->');
|
|
const next = fs.readFileSync(file, 'utf8');
|
|
assert.ok(!next.includes('Evolution Memory'));
|
|
assert.ok(next.includes('Intro'));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('returns false when marker absent', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const file = path.join(tmp, 'AGENTS.md');
|
|
fs.writeFileSync(file, '# Project\n');
|
|
assert.equal(hookAdapter.removeMarkedSection(file, '<!-- nope -->'), false);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('returns false when file missing', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
assert.equal(
|
|
hookAdapter.removeMarkedSection(path.join(tmp, 'missing.md'), '<!-- x -->'),
|
|
false
|
|
);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
});
|
|
|
|
describe('assertSafeConfigDir (PR #94 round-4 + round-5)', () => {
|
|
symlinkIt('throws when the config dir is a symbolic link', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const target = path.join(tmp, 'real-target');
|
|
fs.mkdirSync(target, { recursive: true });
|
|
const linkPath = path.join(tmp, '.codex');
|
|
fs.symlinkSync(target, linkPath, 'dir');
|
|
assert.throws(
|
|
() => hookAdapter.assertSafeConfigDir(linkPath, '.codex'),
|
|
/symbolic link/i,
|
|
'config dir that is a symlink must be refused — hostile workspace could redirect writes outside repo'
|
|
);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('returns silently for a real directory or missing path', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const realDir = path.join(tmp, '.codex');
|
|
fs.mkdirSync(realDir, { recursive: true });
|
|
assert.doesNotThrow(() => hookAdapter.assertSafeConfigDir(realDir, '.codex'));
|
|
assert.doesNotThrow(() => hookAdapter.assertSafeConfigDir(path.join(tmp, '.missing'), '.missing'));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
symlinkIt('codex install/uninstall refuses .codex symlink', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const target = path.join(tmp, 'redirect');
|
|
fs.mkdirSync(target, { recursive: true });
|
|
fs.symlinkSync(target, path.join(tmp, '.codex'), 'dir');
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
assert.throws(
|
|
() => codexAdapter.install({ configRoot: tmp, evolverRoot, force: true }),
|
|
/symbolic link/i
|
|
);
|
|
assert.throws(
|
|
() => codexAdapter.uninstall({ configRoot: tmp }),
|
|
/symbolic link/i
|
|
);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
symlinkIt('claude-code install/uninstall refuses .claude symlink', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const target = path.join(tmp, 'redirect');
|
|
fs.mkdirSync(target, { recursive: true });
|
|
fs.symlinkSync(target, path.join(tmp, '.claude'), 'dir');
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
assert.throws(
|
|
() => claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true }),
|
|
/symbolic link/i
|
|
);
|
|
assert.throws(
|
|
() => claudeAdapter.uninstall({ configRoot: tmp }),
|
|
/symbolic link/i
|
|
);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
// Round-5: a hostile workspace can keep `.codex` as a real directory
|
|
// and only symlink the nested `hooks/` (or `plugins/`) dir. The
|
|
// top-level guard from round-4 missed this, so copyHookScripts /
|
|
// removeHookScripts ran through the symlink and could touch files
|
|
// outside the workspace. Bugbot HIGH severity finding for round-5.
|
|
symlinkIt('rejects symlinked nested subdirs via subdirs option', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const realConfig = path.join(tmp, '.codex');
|
|
fs.mkdirSync(realConfig, { recursive: true });
|
|
const target = path.join(tmp, 'redirect');
|
|
fs.mkdirSync(target, { recursive: true });
|
|
fs.symlinkSync(target, path.join(realConfig, 'hooks'), 'dir');
|
|
assert.throws(
|
|
() => hookAdapter.assertSafeConfigDir(realConfig, '.codex', { subdirs: ['hooks'] }),
|
|
/symbolic link/i,
|
|
'symlinked .codex/hooks must be refused even when .codex itself is real'
|
|
);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
symlinkIt('codex install/uninstall refuses .codex/hooks symlink', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const realConfig = path.join(tmp, '.codex');
|
|
fs.mkdirSync(realConfig, { recursive: true });
|
|
const target = path.join(tmp, 'redirect');
|
|
fs.mkdirSync(target, { recursive: true });
|
|
fs.symlinkSync(target, path.join(realConfig, 'hooks'), 'dir');
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
assert.throws(
|
|
() => codexAdapter.install({ configRoot: tmp, evolverRoot, force: true }),
|
|
/symbolic link/i
|
|
);
|
|
assert.throws(
|
|
() => codexAdapter.uninstall({ configRoot: tmp }),
|
|
/symbolic link/i
|
|
);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
symlinkIt('claude-code install/uninstall refuses .claude/hooks symlink', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const realConfig = path.join(tmp, '.claude');
|
|
fs.mkdirSync(realConfig, { recursive: true });
|
|
const target = path.join(tmp, 'redirect');
|
|
fs.mkdirSync(target, { recursive: true });
|
|
fs.symlinkSync(target, path.join(realConfig, 'hooks'), 'dir');
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
assert.throws(
|
|
() => claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true }),
|
|
/symbolic link/i
|
|
);
|
|
assert.throws(
|
|
() => claudeAdapter.uninstall({ configRoot: tmp }),
|
|
/symbolic link/i
|
|
);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
});
|
|
|
|
describe('evolver-session-end runGit fail/empty distinction (PR #94 round-6)', () => {
|
|
// Round-6 LOW: runGit previously returned `''` for both
|
|
// "command failed" (e.g. no HEAD~1 in fresh repo) and
|
|
// "command succeeded with empty output" (e.g. empty merge).
|
|
// The `||` chain in getGitDiffStats() then incorrectly fell
|
|
// through to the working-tree diff on a successful empty result,
|
|
// surfacing unrelated unstaged changes as the session outcome.
|
|
// Pin the {ok, out} contract at the source level.
|
|
it('runGit returns {ok, out} so callers can distinguish failure from empty', () => {
|
|
const src = fs.readFileSync(
|
|
path.resolve(__dirname, '..', 'src', 'adapters', 'scripts', 'evolver-session-end.js'),
|
|
'utf8'
|
|
);
|
|
assert.ok(
|
|
/return\s*\{\s*ok:\s*true/.test(src),
|
|
'runGit success branch must return an object with `ok: true`'
|
|
);
|
|
assert.ok(
|
|
/return\s*\{\s*ok:\s*false/.test(src),
|
|
'runGit failure branch must return an object with `ok: false`'
|
|
);
|
|
// Make sure the call sites use the .ok flag rather than relying on
|
|
// truthy stdout — the previous bug was a `||` on the stdout string.
|
|
assert.ok(
|
|
/\.ok\s*\?[^?]*runGit\(/.test(src) || /\.ok\s*\?\s*[A-Za-z0-9_]+\.out/.test(src),
|
|
'getGitDiffStats must check .ok before falling back, not chain `||` on output'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('copyHookScripts symlinked-destination guard (PR #94 round-6)', () => {
|
|
// Round-6 HIGH: round-5 closed the directory hole, but a hostile
|
|
// workspace can still pre-create individual hook *files* as symlinks
|
|
// pointing at writable targets outside the project. fs.copyFileSync
|
|
// follows symlinks at the destination, so the source content lands
|
|
// on the attacker's chosen file.
|
|
symlinkIt('refuses to copy when destination file is a symlink', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
const destDir = path.join(tmp, 'hooks');
|
|
fs.mkdirSync(destDir, { recursive: true });
|
|
const hostileTarget = path.join(tmp, 'attacker-target');
|
|
fs.writeFileSync(hostileTarget, 'original\n');
|
|
fs.symlinkSync(
|
|
hostileTarget,
|
|
path.join(destDir, 'evolver-session-end.js'),
|
|
'file'
|
|
);
|
|
assert.throws(
|
|
() => hookAdapter.copyHookScripts(destDir, path.join(evolverRoot, 'src', 'adapters')),
|
|
/symbolic link/i,
|
|
'pre-planted symlink at hook destination must be refused'
|
|
);
|
|
// Attacker target must remain untouched.
|
|
assert.equal(fs.readFileSync(hostileTarget, 'utf8'), 'original\n');
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
});
|
|
|
|
describe('_runtimePaths security (PR #94 round-2)', () => {
|
|
it('findEvolverRoot does not trust process.cwd() / hostile workspace node_modules', () => {
|
|
// A hostile workspace could plant
|
|
// `node_modules/@evomap/evolver/package.json` and previously have it
|
|
// resolved as the trusted evolver root, letting the workspace control
|
|
// findMemoryGraph() — which feeds attacker text into the
|
|
// session-start `additionalContext` (prompt injection).
|
|
// The fix removed `process.cwd()` from the require.resolve paths.
|
|
// The paths array is now built dynamically by
|
|
// _buildInstallSearchPaths() (so we can add Apple Silicon Homebrew
|
|
// / NVM / fnm / Volta / asdf without bloating the call site), so
|
|
// test the runtime invariant: the produced paths must never
|
|
// include process.cwd() or a node_modules directory under it.
|
|
const runtimePaths = require('../src/adapters/scripts/_runtimePaths');
|
|
const paths = runtimePaths.__internals.buildInstallSearchPaths();
|
|
const cwd = process.cwd();
|
|
const cwdNm = path.join(cwd, 'node_modules');
|
|
// The actual security invariant is "process.cwd() itself (or
|
|
// cwd/node_modules) must not appear in the allowlist". The
|
|
// tempting `p.startsWith(cwd + path.sep)` check is too broad —
|
|
// all user-scoped paths are rooted at os.homedir(), so the test
|
|
// would fire false positives whenever cwd === os.homedir() (user
|
|
// runs evolver from their home dir), cwd === '/home' (Linuxbrew
|
|
// path begins with /home/), or cwd === '/opt' (Apple Silicon
|
|
// Homebrew path begins with /opt/). Bugbot PR #165 Medium.
|
|
for (const p of paths) {
|
|
assert.notEqual(p, cwd,
|
|
'install search list MUST NOT include process.cwd() — hostile workspace could plant @evomap/evolver and hijack findMemoryGraph');
|
|
assert.notEqual(p, cwdNm,
|
|
'install search list MUST NOT include cwd/node_modules either');
|
|
}
|
|
});
|
|
|
|
it('findEvolverRoot ignores a hostile node_modules in cwd', () => {
|
|
const tmp = makeTmpDir();
|
|
const origCwd = process.cwd();
|
|
try {
|
|
// Plant a fake @evomap/evolver under the tmp dir.
|
|
const hostile = path.join(tmp, 'node_modules', '@evomap', 'evolver');
|
|
fs.mkdirSync(hostile, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(hostile, 'package.json'),
|
|
JSON.stringify({ name: '@evomap/evolver', version: '0.0.1-hostile' })
|
|
);
|
|
process.chdir(tmp);
|
|
|
|
// Re-require fresh so any internal state is clean. _runtimePaths is
|
|
// pure (no module-level side effects), but this is defensive.
|
|
delete require.cache[require.resolve('../src/adapters/scripts/_runtimePaths')];
|
|
const { findEvolverRoot } = require('../src/adapters/scripts/_runtimePaths');
|
|
const root = findEvolverRoot();
|
|
|
|
// The dev/repo relative walk (which doesn't depend on cwd) should
|
|
// resolve to the real evolver root. Either way, the hostile path
|
|
// must NEVER be returned.
|
|
assert.notEqual(root, hostile,
|
|
'hostile node_modules/@evomap/evolver under cwd must not be selected as evolver root');
|
|
} finally {
|
|
process.chdir(origCwd);
|
|
cleanup(tmp);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// -- Cursor adapter --
|
|
|
|
describe('cursor adapter', () => {
|
|
it('installs hooks correctly', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, '.cursor'), { recursive: true });
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
const result = cursorAdapter.install({ configRoot: tmp, evolverRoot, force: true });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.platform, 'cursor');
|
|
const hooks = JSON.parse(fs.readFileSync(path.join(tmp, '.cursor', 'hooks.json'), 'utf8'));
|
|
assert.ok(hooks.hooks.sessionStart);
|
|
assert.ok(hooks.hooks.afterFileEdit);
|
|
assert.ok(hooks.hooks.stop);
|
|
assert.equal(hooks._evolver_managed, true);
|
|
assert.ok(fs.existsSync(path.join(tmp, '.cursor', 'hooks', 'evolver-session-start.js')));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('uninstalls hooks correctly', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, '.cursor'), { recursive: true });
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
cursorAdapter.install({ configRoot: tmp, evolverRoot, force: true });
|
|
const result = cursorAdapter.uninstall({ configRoot: tmp, evolverRoot });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.removed, true);
|
|
assert.ok(!fs.existsSync(path.join(tmp, '.cursor', 'hooks', 'evolver-session-start.js')));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('buildHooksJson returns valid structure', () => {
|
|
const hooks = cursorAdapter.buildHooksJson('/evolver', false);
|
|
assert.equal(hooks.version, 1);
|
|
assert.ok(hooks.hooks.sessionStart[0].command.includes('evolver-session-start'));
|
|
assert.ok(hooks.hooks.afterFileEdit[0].command.includes('evolver-signal-detect'));
|
|
assert.ok(hooks.hooks.stop[0].command.includes('evolver-session-end'));
|
|
});
|
|
|
|
it('buildHooksJson user-level uses ./hooks/ prefix', () => {
|
|
const hooks = cursorAdapter.buildHooksJson('/evolver', true);
|
|
assert.ok(hooks.hooks.sessionStart[0].command.startsWith('node ./hooks/'));
|
|
});
|
|
});
|
|
|
|
// -- Claude Code adapter --
|
|
|
|
describe('claudeCode adapter', () => {
|
|
it('installs hooks and CLAUDE.md', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, '.claude'), { recursive: true });
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
const result = claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.platform, 'claude-code');
|
|
const settings = JSON.parse(fs.readFileSync(path.join(tmp, '.claude', 'settings.json'), 'utf8'));
|
|
assert.ok(settings.hooks.SessionStart);
|
|
assert.ok(settings.hooks.PostToolUse);
|
|
assert.ok(settings.hooks.Stop);
|
|
assert.ok(fs.existsSync(path.join(tmp, 'CLAUDE.md')));
|
|
const claudeMd = fs.readFileSync(path.join(tmp, 'CLAUDE.md'), 'utf8');
|
|
assert.ok(claudeMd.includes('Evolution Memory'));
|
|
assert.ok(claudeMd.includes('Do not narrate routine Evolver checks'));
|
|
assert.ok(!claudeMd.includes('For substantive tasks'));
|
|
assert.ok(!claudeMd.includes('gep_recall'));
|
|
assert.ok(!claudeMd.includes('gep_record_outcome'));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('uninstalls hooks and CLAUDE.md section', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, '.claude'), { recursive: true });
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
|
|
const result = claudeAdapter.uninstall({ configRoot: tmp });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.removed, true);
|
|
const claudeMd = fs.readFileSync(path.join(tmp, 'CLAUDE.md'), 'utf8');
|
|
assert.ok(!claudeMd.includes('evolver-evolution-memory'));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('install preserves user-installed Stop hook (#539)', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const claudeDir = path.join(tmp, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
hooks: {
|
|
Stop: [
|
|
{ hooks: [{ type: 'command', command: 'node user-cleanup.js' }] },
|
|
],
|
|
},
|
|
}));
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
claudeAdapter.install({ configRoot: tmp, evolverRoot, force: true });
|
|
const merged = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
assert.equal(merged.hooks.Stop.length, 2,
|
|
'user Stop entry + evolver Stop entry must both be present');
|
|
const cmds = merged.hooks.Stop.flatMap(m => (m.hooks || []).map(h => h.command));
|
|
assert.ok(cmds.includes('node user-cleanup.js'));
|
|
assert.ok(cmds.some(c => c.includes('evolver-session-end')));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('install refreshes managed legacy evolver hooks without force', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const claudeDir = path.join(tmp, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
_evolver_managed: true,
|
|
hooks: {
|
|
SessionStart: [
|
|
{ hooks: [{ type: 'command', command: 'node .claude/hooks/evolver-session-start.js' }] },
|
|
],
|
|
Stop: [
|
|
{ hooks: [
|
|
{ type: 'command', command: 'node user-cleanup.js' },
|
|
{ type: 'command', command: 'node .claude/hooks/evolver-session-end.js' },
|
|
] },
|
|
],
|
|
},
|
|
}));
|
|
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
const result = claudeAdapter.install({ configRoot: tmp, evolverRoot, force: false });
|
|
assert.equal(result.ok, true);
|
|
assert.notEqual(result.skipped, true);
|
|
|
|
const merged = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
const commands = collectCommands(merged);
|
|
assert.ok(commands.includes('node user-cleanup.js'), 'user Stop hook must be preserved');
|
|
assert.ok(!commands.some(c => c.includes('node .claude/hooks/')),
|
|
'legacy relative evolver hook commands must be replaced');
|
|
assert.equal(commands.filter(c => c.includes('evolver-session-start')).length, 1);
|
|
assert.equal(commands.filter(c => c.includes('evolver-session-end')).length, 1);
|
|
assert.ok(commands.every(c => {
|
|
if (!hookAdapter.isEvolverHookCommand(c)) return true;
|
|
return c.startsWith('node -e ');
|
|
}), 'all evolver hook commands must use the safe node -e wrapper');
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('uninstall strips evolver hooks even without _evolver_managed marker (#538)', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const claudeDir = path.join(tmp, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
// Hand-edited / older install: marker dropped but evolver entry remains.
|
|
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
hooks: {
|
|
Stop: [
|
|
{ hooks: [{ type: 'command', command: 'node .claude/hooks/evolver-session-end.js' }] },
|
|
{ hooks: [{ type: 'command', command: 'node user-cleanup.js' }] },
|
|
],
|
|
},
|
|
}));
|
|
|
|
claudeAdapter.uninstall({ configRoot: tmp });
|
|
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
const remaining = (after.hooks && after.hooks.Stop)
|
|
? after.hooks.Stop.flatMap(m => (m.hooks || []).map(h => h.command))
|
|
: [];
|
|
assert.ok(!remaining.some(c => c.includes('evolver-session-end')),
|
|
'evolver hook must be filtered even without marker');
|
|
assert.ok(remaining.includes('node user-cleanup.js'),
|
|
'user hook must remain');
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('uninstall persists inner-array filter when matcher mixes evolver + user hooks (PR #94)', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const claudeDir = path.join(tmp, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
// No _evolver_managed marker, single matcher contains both evolver
|
|
// and user hooks. Outer array length is unchanged after filtering;
|
|
// the bug was that `touched` only flipped on outer-length changes.
|
|
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
hooks: {
|
|
Stop: [
|
|
{ hooks: [
|
|
{ type: 'command', command: 'node .claude/hooks/evolver-session-end.js' },
|
|
{ type: 'command', command: 'node user-cleanup.js' },
|
|
] },
|
|
],
|
|
},
|
|
}));
|
|
|
|
claudeAdapter.uninstall({ configRoot: tmp });
|
|
const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
const remaining = (after.hooks && after.hooks.Stop)
|
|
? after.hooks.Stop.flatMap(m => (m.hooks || []).map(h => h.command))
|
|
: [];
|
|
assert.ok(!remaining.some(c => c.includes('evolver-session-end')),
|
|
'evolver hook must be filtered out and the change persisted to disk');
|
|
assert.ok(remaining.includes('node user-cleanup.js'),
|
|
'user hook in same matcher must remain');
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('buildClaudeHooks produces Claude Code HookMatcher structure', () => {
|
|
const hooks = claudeAdapter.buildClaudeHooks('/evolver');
|
|
for (const event of ['SessionStart', 'PostToolUse', 'Stop']) {
|
|
const matchers = hooks.hooks[event];
|
|
assert.ok(Array.isArray(matchers), `${event} must be an array`);
|
|
assert.ok(matchers.length > 0, `${event} must have matchers`);
|
|
for (const matcher of matchers) {
|
|
assert.ok(Array.isArray(matcher.hooks), `${event} matcher must have .hooks array`);
|
|
for (const cmd of matcher.hooks) {
|
|
assert.equal(cmd.type, 'command');
|
|
assert.equal(typeof cmd.command, 'string');
|
|
assert.ok(cmd.command.length > 0);
|
|
}
|
|
}
|
|
}
|
|
assert.equal(hooks.hooks.PostToolUse[0].matcher, 'Write');
|
|
});
|
|
|
|
function collectCommands(hooks) {
|
|
const commands = [];
|
|
for (const matchers of Object.values(hooks.hooks)) {
|
|
for (const matcher of matchers) {
|
|
for (const hook of matcher.hooks || []) commands.push(hook.command);
|
|
}
|
|
}
|
|
return commands;
|
|
}
|
|
|
|
it('builds absolute, encoded hook commands rooted at configRoot with spaces (#590)', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const configRoot = path.join(tmp, 'Claude Project With Spaces');
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
const result = claudeAdapter.install({ configRoot, evolverRoot, force: true });
|
|
assert.equal(result.ok, true);
|
|
|
|
const settings = JSON.parse(fs.readFileSync(path.join(configRoot, '.claude', 'settings.json'), 'utf8'));
|
|
const commands = collectCommands(settings);
|
|
const scripts = [
|
|
'evolver-session-start.js',
|
|
'evolver-task-recall.js',
|
|
'evolver-signal-detect.js',
|
|
'evolver-session-end.js',
|
|
];
|
|
assert.equal(commands.length, scripts.length);
|
|
for (const scriptName of scripts) {
|
|
const scriptPath = path.join(configRoot, '.claude', 'hooks', scriptName);
|
|
const command = commands.find(c => c.includes(scriptName));
|
|
assert.ok(command, `${scriptName} hook command must be present`);
|
|
assert.ok(!command.includes('node .claude/hooks/'), 'hook command must not use a relative script path');
|
|
assert.ok(!command.includes(scriptPath), `hook command must not expose raw script path ${scriptPath}`);
|
|
assert.ok(!command.includes(configRoot), 'hook command must not expose raw config root');
|
|
assert.match(command, /^node -e (["']).+\1 \S+\.js$/);
|
|
// The plaintext basename keeps uninstall/reinstall matching working.
|
|
assert.ok(hookAdapter.isEvolverHookCommand(command), 'must stay matchable as evolver-owned');
|
|
}
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('keeps shell-expansion bytes out of generated hook commands', () => {
|
|
const configRoot = path.join(os.tmpdir(), 'Claude $(touch pwned) %TEMP% `id`');
|
|
const commands = collectCommands(claudeAdapter.buildClaudeHooks('/evolver', configRoot));
|
|
assert.ok(commands.length > 0);
|
|
for (const command of commands) {
|
|
assert.ok(!command.includes('$('), 'no command-substitution bytes');
|
|
assert.ok(!command.includes('`'), 'no backtick bytes');
|
|
assert.ok(!command.includes('%TEMP%'), 'no Windows env-expansion bytes');
|
|
assert.ok(!command.includes(configRoot), 'no raw config path');
|
|
}
|
|
});
|
|
|
|
it('claudeCode reuses the shared hookAdapter.buildSafeNodeHookCommand', () => {
|
|
const configRoot = path.join(os.tmpdir(), 'shared-impl-check');
|
|
const command = claudeAdapter.buildClaudeHooks('/evolver', configRoot).hooks.SessionStart[0].hooks[0].command;
|
|
const expected = hookAdapter.buildSafeNodeHookCommand(
|
|
path.resolve(configRoot, '.claude', 'hooks', 'evolver-session-start.js')
|
|
);
|
|
assert.equal(command, expected);
|
|
});
|
|
|
|
it('runs a hook path containing $() through POSIX sh without expansion (#590)', { skip: process.platform === 'win32' }, () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const proofPath = path.join(tmp, 'proof');
|
|
const configRoot = path.join(tmp, `Project $(touch ${proofPath})`);
|
|
const hookDir = path.join(configRoot, '.claude', 'hooks');
|
|
fs.mkdirSync(hookDir, { recursive: true });
|
|
const scriptPath = path.join(hookDir, 'evolver-session-start.js');
|
|
const ranPath = path.join(tmp, 'ran');
|
|
fs.writeFileSync(scriptPath, `require('fs').writeFileSync(${JSON.stringify(ranPath)}, 'ok')\n`);
|
|
|
|
const hooks = claudeAdapter.buildClaudeHooks('/evolver', configRoot);
|
|
const command = hooks.hooks.SessionStart[0].hooks[0].command;
|
|
assert.ok(!command.includes('$('), 'generated command must not contain raw $() path text');
|
|
|
|
const result = spawnSync('/bin/sh', ['-c', command], {
|
|
encoding: 'utf8',
|
|
// The wrapper cold-starts node twice (outer `node -e` + inner script),
|
|
// so keep a generous budget for slow/loaded CI machines.
|
|
timeout: 15000,
|
|
});
|
|
assert.equal(result.status, 0, `hook command should succeed. stderr=${result.stderr}`);
|
|
assert.ok(fs.existsSync(ranPath), 'hook script should execute via the decoded path');
|
|
assert.ok(!fs.existsSync(proofPath), 'command substitution in the path must not execute');
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
});
|
|
|
|
// -- Codex adapter --
|
|
|
|
describe('codex adapter', () => {
|
|
it('installs hooks, config.toml, and AGENTS.md', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, '.codex'), { recursive: true });
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
const result = codexAdapter.install({ configRoot: tmp, evolverRoot, force: true });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.platform, 'codex');
|
|
const hooks = JSON.parse(fs.readFileSync(path.join(tmp, '.codex', 'hooks.json'), 'utf8'));
|
|
assert.ok(hooks.hooks.SessionStart);
|
|
assert.ok(hooks.hooks.Stop);
|
|
const toml = fs.readFileSync(path.join(tmp, '.codex', 'config.toml'), 'utf8');
|
|
assert.ok(toml.includes('codex_hooks = true'));
|
|
assert.ok(fs.existsSync(path.join(tmp, 'AGENTS.md')));
|
|
const agentsMd = fs.readFileSync(path.join(tmp, 'AGENTS.md'), 'utf8');
|
|
assert.ok(agentsMd.includes('Do not narrate routine Evolver checks'));
|
|
assert.ok(!agentsMd.includes('For substantive tasks'));
|
|
assert.ok(!agentsMd.includes('gep_recall'));
|
|
assert.ok(!agentsMd.includes('gep_record_outcome'));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('ensureConfigToml adds feature flag', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const codexDir = path.join(tmp, '.codex');
|
|
fs.mkdirSync(codexDir, { recursive: true });
|
|
const changed = codexAdapter.ensureConfigToml(codexDir);
|
|
assert.equal(changed, true);
|
|
const toml = fs.readFileSync(path.join(codexDir, 'config.toml'), 'utf8');
|
|
assert.ok(toml.includes('[features]'));
|
|
assert.ok(toml.includes('codex_hooks = true'));
|
|
const noChange = codexAdapter.ensureConfigToml(codexDir);
|
|
assert.equal(noChange, false);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('uninstalls hooks and AGENTS.md section', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, '.codex'), { recursive: true });
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
codexAdapter.install({ configRoot: tmp, evolverRoot, force: true });
|
|
const result = codexAdapter.uninstall({ configRoot: tmp });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.removed, true);
|
|
const agentsMd = fs.readFileSync(path.join(tmp, 'AGENTS.md'), 'utf8');
|
|
assert.ok(!agentsMd.includes('evolver-evolution-memory'));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('uninstall cleans codex_hooks flag from config.toml (#538)', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, '.codex'), { recursive: true });
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
codexAdapter.install({ configRoot: tmp, evolverRoot, force: true });
|
|
// Simulate a user adding their own [features] entry alongside evolver's.
|
|
const tomlPath = path.join(tmp, '.codex', 'config.toml');
|
|
const before = fs.readFileSync(tomlPath, 'utf8');
|
|
fs.writeFileSync(tomlPath, before + 'user_feature = true\n');
|
|
|
|
codexAdapter.uninstall({ configRoot: tmp });
|
|
const after = fs.readFileSync(tomlPath, 'utf8');
|
|
assert.ok(!after.includes('codex_hooks'), 'codex_hooks line must be removed');
|
|
assert.ok(after.includes('user_feature'), 'unrelated user feature must be preserved');
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('uninstall removes [features] header when only codex_hooks lived there', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, '.codex'), { recursive: true });
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
codexAdapter.install({ configRoot: tmp, evolverRoot, force: true });
|
|
codexAdapter.uninstall({ configRoot: tmp });
|
|
const after = fs.readFileSync(path.join(tmp, '.codex', 'config.toml'), 'utf8');
|
|
assert.ok(!after.includes('[features]'),
|
|
'orphan [features] block should be removed when empty');
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('cleanConfigToml preserves [features] when user entries follow a blank line (PR #94 round-3)', () => {
|
|
// Bugbot round-3 finding: the multiline `$` in
|
|
// /\[features\]\s*\n(?=\s*\[|\s*$)/m could match a blank line
|
|
// mid-file and strand user entries below the removed header.
|
|
// Verify several layouts that previously worried Bugbot.
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const codexDir = path.join(tmp, '.codex');
|
|
fs.mkdirSync(codexDir, { recursive: true });
|
|
const tomlPath = path.join(codexDir, 'config.toml');
|
|
|
|
const layouts = [
|
|
'[features]\ncodex_hooks = true\n\nuser_feature = true\n',
|
|
'[features]\ncodex_hooks = true\n\n# user comment\nuser_feature = true\n',
|
|
'[features]\ncodex_hooks = true\nuser_feature = true\n\n[other]\nfoo = 1\n',
|
|
];
|
|
for (const layout of layouts) {
|
|
fs.writeFileSync(tomlPath, layout);
|
|
codexAdapter.cleanConfigToml(codexDir);
|
|
const after = fs.readFileSync(tomlPath, 'utf8');
|
|
assert.ok(!after.includes('codex_hooks'),
|
|
`codex_hooks must be removed (layout=${JSON.stringify(layout)})`);
|
|
assert.ok(after.includes('[features]'),
|
|
`[features] must NOT be removed when user entries remain (layout=${JSON.stringify(layout)} -> ${JSON.stringify(after)})`);
|
|
assert.ok(after.includes('user_feature'),
|
|
`user entry must be preserved (layout=${JSON.stringify(layout)})`);
|
|
}
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('cleanConfigToml drops [features] when section becomes empty', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const codexDir = path.join(tmp, '.codex');
|
|
fs.mkdirSync(codexDir, { recursive: true });
|
|
const tomlPath = path.join(codexDir, 'config.toml');
|
|
|
|
// (input, expectedFeaturesPresent)
|
|
const layouts = [
|
|
['[features]\ncodex_hooks = true\n', false],
|
|
['[features]\ncodex_hooks = true\n\n[other]\nfoo = 1\n', false],
|
|
['[features]\ncodex_hooks = true\n[other]\nfoo = 1\n', false],
|
|
];
|
|
for (const [layout, shouldKeep] of layouts) {
|
|
fs.writeFileSync(tomlPath, layout);
|
|
codexAdapter.cleanConfigToml(codexDir);
|
|
const after = fs.readFileSync(tomlPath, 'utf8');
|
|
assert.ok(!after.includes('codex_hooks'), 'codex_hooks must be removed');
|
|
assert.equal(after.includes('[features]'), shouldKeep,
|
|
`[features] presence mismatch (layout=${JSON.stringify(layout)} -> ${JSON.stringify(after)})`);
|
|
}
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('install preserves user-installed hooks under same event (#539)', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, '.codex'), { recursive: true });
|
|
const hooksJsonPath = path.join(tmp, '.codex', 'hooks.json');
|
|
fs.writeFileSync(hooksJsonPath, JSON.stringify({
|
|
hooks: {
|
|
Stop: [{ type: 'command', command: 'node my-tool.js' }],
|
|
PostToolUse: [{ type: 'command', command: 'node my-watcher.js' }],
|
|
},
|
|
}));
|
|
const evolverRoot = path.resolve(__dirname, '..');
|
|
codexAdapter.install({ configRoot: tmp, evolverRoot, force: true });
|
|
const merged = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
|
const stopCmds = merged.hooks.Stop.map(h => h.command);
|
|
assert.ok(stopCmds.includes('node my-tool.js'), 'user Stop hook must be preserved');
|
|
assert.ok(stopCmds.some(c => c.includes('evolver-session-end')), 'evolver Stop hook must be added');
|
|
|
|
const postCmds = merged.hooks.PostToolUse.map(h => h.command);
|
|
assert.ok(postCmds.includes('node my-watcher.js'));
|
|
assert.ok(postCmds.some(c => c.includes('evolver-signal-detect')));
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #547: setup-hooks must copy every helper required by the entry-point hooks
|
|
//
|
|
// Two regressions of this exact shape have shipped:
|
|
// - PR #94 review caught `_runtimePaths.js` missing from the copy list.
|
|
// - Issue #547 (rendigua, v1.87.0): `_memoryFiltering.js` was added to
|
|
// evolver-session-start.js but not to hookAdapter.js's copy list, so
|
|
// fresh installs crashed immediately with
|
|
// `Error: Cannot find module './_memoryFiltering'`.
|
|
//
|
|
// This test scans every `require('./_xxx')` in the source adapter scripts
|
|
// and asserts the target file is present in `src/adapters/scripts/` AND
|
|
// that an end-to-end `installHooks` actually copies it into the
|
|
// destination dir. Any future helper added but forgotten in
|
|
// hookAdapter.js's list will trip the assertion immediately.
|
|
// ---------------------------------------------------------------------------
|
|
describe('setup-hooks copies every helper required by the entry-point scripts (#547)', () => {
|
|
const scriptsDir = path.resolve(__dirname, '..', 'src', 'adapters', 'scripts');
|
|
|
|
function collectRelativeRequires() {
|
|
const entries = fs.readdirSync(scriptsDir).filter(f => f.endsWith('.js'));
|
|
const requires = new Set();
|
|
for (const entry of entries) {
|
|
const src = fs.readFileSync(path.join(scriptsDir, entry), 'utf8');
|
|
// Match `require('./foo')` and `require("./foo")` for any "./..."
|
|
// path that targets a sibling file (not a node_modules package).
|
|
const re = /require\(\s*['"](\.\/[^'"]+)['"]\s*\)/g;
|
|
let m;
|
|
while ((m = re.exec(src)) !== null) {
|
|
// Normalise: ensure .js suffix for matching against directory list.
|
|
let target = m[1].replace(/^\.\//, '');
|
|
if (target.includes('/')) continue; // only verify same-dir sibling helpers
|
|
if (!target.endsWith('.js')) target += '.js';
|
|
requires.add(target);
|
|
}
|
|
}
|
|
return [...requires];
|
|
}
|
|
|
|
it('every local require in adapter scripts points to a file that actually exists in scripts/', () => {
|
|
const requires = collectRelativeRequires();
|
|
assert.ok(requires.length > 0, 'expected at least one local require to verify');
|
|
const existingScripts = new Set(fs.readdirSync(scriptsDir));
|
|
for (const target of requires) {
|
|
assert.ok(existingScripts.has(target),
|
|
`adapter script requires './${target.replace(/\.js$/, '')}' but src/adapters/scripts/${target} does not exist`);
|
|
}
|
|
});
|
|
|
|
it('every required helper is copied by installHooks (Codex)', () => {
|
|
const requires = collectRelativeRequires();
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const hooksDir = path.join(tmp, '.codex', 'hooks');
|
|
const evolverRoot = path.resolve(__dirname, '..', 'src', 'adapters');
|
|
hookAdapter.copyHookScripts(hooksDir, evolverRoot);
|
|
const installed = new Set(fs.readdirSync(hooksDir));
|
|
for (const target of requires) {
|
|
assert.ok(installed.has(target),
|
|
`evolver-session-*.js requires './${target.replace(/\.js$/, '')}' but setup-hooks did not copy ` +
|
|
`src/adapters/scripts/${target} into the destination dir (this was the root cause of #547)`);
|
|
}
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('every helper copied by install is also removed by uninstall (no orphan files)', () => {
|
|
const requires = collectRelativeRequires();
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
const hooksDir = path.join(tmp, '.codex', 'hooks');
|
|
const evolverRoot = path.resolve(__dirname, '..', 'src', 'adapters');
|
|
hookAdapter.copyHookScripts(hooksDir, evolverRoot);
|
|
hookAdapter.removeHookScripts(hooksDir);
|
|
// After uninstall, none of the required-helper files should remain.
|
|
const remaining = fs.existsSync(hooksDir) ? new Set(fs.readdirSync(hooksDir)) : new Set();
|
|
for (const target of requires) {
|
|
assert.ok(!remaining.has(target),
|
|
`setup-hooks --uninstall left orphan file ${target} (install copies it but uninstall does not remove it)`);
|
|
}
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
});
|