mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-03 07:58:47 +02:00
chore: integrate current main into hardening
This commit is contained in:
@@ -43,6 +43,29 @@ function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function withTempDir(prefix, fn) {
|
||||
const dirPath = createTempDir(prefix);
|
||||
try {
|
||||
return fn(dirPath);
|
||||
} finally {
|
||||
cleanup(dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
test('withTempDir removes temp directories when the callback throws', () => {
|
||||
let createdDir = '';
|
||||
assert.throws(() => {
|
||||
withTempDir('ecc-test-', dirPath => {
|
||||
createdDir = dirPath;
|
||||
assert.ok(fs.existsSync(createdDir));
|
||||
throw new Error('fixture failure');
|
||||
});
|
||||
}, /fixture failure/);
|
||||
|
||||
assert.ok(createdDir);
|
||||
assert.ok(!fs.existsSync(createdDir));
|
||||
});
|
||||
|
||||
function writeFile(rootDir, relativePath, content) {
|
||||
const targetPath = path.join(rootDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
@@ -247,6 +270,43 @@ test('readFrontmatter parses array tools field', () => {
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('readFrontmatter preserves scoped tools in legacy flow sequences', () => {
|
||||
const { readFrontmatter } = require(SCRIPT);
|
||||
withTempDir('ecc-test-', tempDir => {
|
||||
writeFile(tempDir, 'agent.md', [
|
||||
'---',
|
||||
'name: scoped-agent',
|
||||
'tools: [Agent(worker, researcher), Read, Bash(git commit:*, git status:*)]',
|
||||
'---',
|
||||
'body',
|
||||
].join('\n'));
|
||||
|
||||
const fm = readFrontmatter(path.join(tempDir, 'agent.md'));
|
||||
assert.deepStrictEqual(fm.tools, [
|
||||
'Agent(worker, researcher)',
|
||||
'Read',
|
||||
'Bash(git commit:*, git status:*)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('readFrontmatter normalizes comma-separated scalar tools to an array', () => {
|
||||
const { readFrontmatter } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
writeFile(testRoot, 'agent.md', [
|
||||
'---',
|
||||
'name: test-agent',
|
||||
'tools: Bash, Read, Write',
|
||||
'---',
|
||||
'# Body',
|
||||
].join('\n'));
|
||||
|
||||
const fm = readFrontmatter(path.join(testRoot, 'agent.md'));
|
||||
assert.ok(Array.isArray(fm.tools));
|
||||
assert.deepStrictEqual(fm.tools, ['Bash', 'Read', 'Write']);
|
||||
cleanup(testRoot);
|
||||
});
|
||||
|
||||
test('readFrontmatter handles quoted values', () => {
|
||||
const { readFrontmatter } = require(SCRIPT);
|
||||
testRoot = createTempDir('ecc-test-');
|
||||
@@ -324,7 +384,7 @@ test('loadAgents loads agent markdown files', () => {
|
||||
'name: typescript-reviewer',
|
||||
'description: Reviews TypeScript code',
|
||||
'model: claude-sonnet-4-6',
|
||||
'tools: [Bash, Read, Write, Grep]',
|
||||
'tools: Bash, Read, Write, Grep',
|
||||
'---',
|
||||
'# TypeScript Reviewer',
|
||||
'You are a TypeScript code reviewer.',
|
||||
@@ -334,7 +394,7 @@ test('loadAgents loads agent markdown files', () => {
|
||||
'name: python-reviewer',
|
||||
'description: Reviews Python code',
|
||||
'model: claude-opus-4-8',
|
||||
'tools: [Bash, Read]',
|
||||
'tools: Bash, Read',
|
||||
'---',
|
||||
'# Python Reviewer',
|
||||
].join('\n'));
|
||||
|
||||
@@ -99,6 +99,36 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('adapts comma-separated scalar Claude Code tools', () => {
|
||||
const tempDir = createTempDir();
|
||||
const agentsDir = path.join(tempDir, '.gemini', 'agents');
|
||||
|
||||
try {
|
||||
writeAgent(
|
||||
agentsDir,
|
||||
'docs-lookup.md',
|
||||
[
|
||||
'---',
|
||||
'name: docs-lookup',
|
||||
'description: Documentation lookup agent',
|
||||
'tools: Read, Grep, mcp__context7__resolve-library-id, mcp__context7__query-docs',
|
||||
'model: sonnet',
|
||||
'---',
|
||||
'',
|
||||
'Body'
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const result = run([agentsDir]);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const updated = fs.readFileSync(path.join(agentsDir, 'docs-lookup.md'), 'utf8');
|
||||
assert.ok(updated.includes('tools: ["read_file", "grep_search", "mcp_context7_resolve_library_id", "mcp_context7_query_docs"]'));
|
||||
} finally {
|
||||
cleanupTempDir(tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('defaults to the cwd .gemini/agents directory', () => {
|
||||
const tempDir = createTempDir();
|
||||
const agentsDir = path.join(tempDir, '.gemini', 'agents');
|
||||
|
||||
@@ -100,8 +100,8 @@ function runTests() {
|
||||
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', 'ecc', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'coding-standards', 'SKILL.md')));
|
||||
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')));
|
||||
|
||||
const statePath = path.join(homeDir, '.claude', 'ecc', 'install-state.json');
|
||||
@@ -133,23 +133,23 @@ function runTests() {
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
const skillPath = path.join(claudeRoot, 'skills', 'ecc', 'react-patterns', 'SKILL.md');
|
||||
const skillPath = path.join(claudeRoot, 'skills', 'react-patterns', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillPath), 'react-patterns SKILL.md should be installed');
|
||||
|
||||
const content = fs.readFileSync(skillPath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('../../../rules/ecc/react/'),
|
||||
content.includes('../../rules/ecc/react/'),
|
||||
'source-relative rules link should be rewritten for the ecc/ namespace'
|
||||
);
|
||||
assert.ok(
|
||||
!content.includes('](../../rules/'),
|
||||
'no un-namespaced ](../../rules/ links should remain'
|
||||
!content.includes('](../../rules/react/'),
|
||||
'no un-namespaced ](../../rules/react/ links should remain'
|
||||
);
|
||||
|
||||
// The rewritten link must resolve to a file that actually exists on disk.
|
||||
const linkTarget = path.join(
|
||||
path.dirname(skillPath),
|
||||
'../../../rules/ecc/react/hooks.md'
|
||||
'../../rules/ecc/react/hooks.md'
|
||||
);
|
||||
assert.ok(fs.existsSync(linkTarget), 'rewritten link target should exist');
|
||||
} finally {
|
||||
@@ -365,7 +365,10 @@ function runTests() {
|
||||
assert.ok(result.stdout.includes('Mode: manifest'));
|
||||
assert.ok(result.stdout.includes('Profile: core'));
|
||||
assert.ok(result.stdout.includes('Included components: (none)'));
|
||||
assert.ok(result.stdout.includes('Selected modules: rules-core, agents-core, commands-core, hooks-runtime, platform-configs, workflow-quality'));
|
||||
assert.ok(result.stdout.includes(
|
||||
'Selected modules: rules-core, agents-core, commands-core, hooks-runtime, '
|
||||
+ 'platform-configs, skill-unified-memory, workflow-quality'
|
||||
));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
@@ -404,7 +407,10 @@ function runTests() {
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('Mode: manifest'));
|
||||
assert.ok(result.stdout.includes('Profile: minimal'));
|
||||
assert.ok(result.stdout.includes('Selected modules: rules-core, agents-core, commands-core, platform-configs, workflow-quality'));
|
||||
assert.ok(result.stdout.includes(
|
||||
'Selected modules: rules-core, agents-core, commands-core, platform-configs, '
|
||||
+ 'skill-unified-memory, workflow-quality'
|
||||
));
|
||||
assert.ok(!result.stdout.includes('hooks-runtime'));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json')));
|
||||
} finally {
|
||||
@@ -462,11 +468,101 @@ function runTests() {
|
||||
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('user-owned'), result.stdout);
|
||||
assert.ok(result.stdout.includes('Skipped operations:'), result.stdout);
|
||||
|
||||
assert.strictEqual(fs.readFileSync(userRulePath, 'utf8'), '# User custom rule\n');
|
||||
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User custom skill\n');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'verification-loop', 'SKILL.md')));
|
||||
const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json'));
|
||||
assert.ok(!state.operations.some(operation => (
|
||||
operation.destinationPath.startsWith(path.join(claudeRoot, 'skills', 'tdd-workflow'))
|
||||
)));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reports applied and skipped user-owned Claude skill operations in JSON', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const userSkillPath = path.join(
|
||||
homeDir,
|
||||
'.claude',
|
||||
'skills',
|
||||
'tdd-workflow',
|
||||
'SKILL.md'
|
||||
);
|
||||
fs.mkdirSync(path.dirname(userSkillPath), { recursive: true });
|
||||
fs.writeFileSync(userSkillPath, '# User custom skill\n');
|
||||
|
||||
const result = run(['--skills', 'tdd-workflow', '--json'], {
|
||||
cwd: projectDir,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.dryRun, false);
|
||||
assert.ok(payload.result.plannedOperations.length > 0);
|
||||
assert.ok(payload.result.operations.length > 0);
|
||||
assert.ok(payload.result.skippedOperations.length > 0);
|
||||
assert.strictEqual(
|
||||
payload.result.operations.length + payload.result.skippedOperations.length,
|
||||
payload.result.plannedOperations.length
|
||||
);
|
||||
assert.ok(payload.result.skippedOperations.every(operation => (
|
||||
operation.destinationPath.startsWith(path.dirname(userSkillPath))
|
||||
)));
|
||||
assert.ok(!payload.result.operations.some(operation => (
|
||||
operation.destinationPath.startsWith(path.dirname(userSkillPath))
|
||||
)));
|
||||
assert.ok(payload.result.warnings.some(warning => warning.includes('user-owned')));
|
||||
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User custom skill\n');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('dry-run reports the same user-owned Claude skill conflicts as apply', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const userSkillRoot = path.join(
|
||||
homeDir,
|
||||
'.claude',
|
||||
'skills',
|
||||
'tdd-workflow'
|
||||
);
|
||||
const userSkillPath = path.join(userSkillRoot, 'SKILL.md');
|
||||
fs.mkdirSync(userSkillRoot, { recursive: true });
|
||||
fs.writeFileSync(userSkillPath, '# User custom skill\n');
|
||||
|
||||
const result = run(
|
||||
['--skills', 'tdd-workflow', '--dry-run', '--json'],
|
||||
{ cwd: projectDir, homeDir }
|
||||
);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.dryRun, true);
|
||||
assert.ok(payload.plan.plannedOperations.length > 0);
|
||||
assert.ok(payload.plan.skippedOperations.length > 0);
|
||||
assert.ok(payload.plan.warnings.some(warning => warning.includes('user-owned')));
|
||||
assert.ok(payload.plan.skippedOperations.every(operation => (
|
||||
operation.destinationPath.startsWith(userSkillRoot)
|
||||
)));
|
||||
assert.ok(!payload.plan.operations.some(operation => (
|
||||
operation.destinationPath.startsWith(userSkillRoot)
|
||||
)));
|
||||
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User custom skill\n');
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
@@ -491,7 +587,14 @@ function runTests() {
|
||||
assert.strictEqual(state.request.legacyMode, false);
|
||||
assert.deepStrictEqual(
|
||||
state.resolution.selectedModules,
|
||||
['rules-core', 'agents-core', 'commands-core', 'platform-configs', 'workflow-quality']
|
||||
[
|
||||
'rules-core',
|
||||
'agents-core',
|
||||
'commands-core',
|
||||
'platform-configs',
|
||||
'skill-unified-memory',
|
||||
'workflow-quality'
|
||||
]
|
||||
);
|
||||
assert.ok(state.resolution.skippedModules.includes('hooks-runtime'));
|
||||
assert.ok(!state.resolution.skippedModules.includes('workflow-quality'));
|
||||
@@ -880,8 +983,8 @@ function runTests() {
|
||||
const result = run(['--config', configPath], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'security-review', 'SKILL.md')));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'dmux-workflows', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'security-review', 'SKILL.md')));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'dmux-workflows', 'SKILL.md')));
|
||||
|
||||
const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
|
||||
assert.strictEqual(state.request.profile, 'developer');
|
||||
@@ -912,8 +1015,8 @@ function runTests() {
|
||||
const result = run([], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'security-review', 'SKILL.md')));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'dmux-workflows', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'security-review', 'SKILL.md')));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'dmux-workflows', 'SKILL.md')));
|
||||
|
||||
const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
|
||||
assert.strictEqual(state.request.profile, 'developer');
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const { PassThrough } = require('stream');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const SERVER = path.join(__dirname, '..', '..', 'scripts', 'memory-mcp.mjs');
|
||||
const {
|
||||
MAX_RESULTS,
|
||||
resolveVaultRoots,
|
||||
saveMemory,
|
||||
} = require('../../scripts/lib/memory-vault');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` PASS ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` ${error.stack || error.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function createFixture(extraEnv = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-mcp-'));
|
||||
const projectRoot = path.join(root, 'project');
|
||||
const homeDir = path.join(root, 'home');
|
||||
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
return {
|
||||
root,
|
||||
projectRoot,
|
||||
env: Object.fromEntries(
|
||||
Object.entries({
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
ECC_MEMORY_PROJECT_ROOT: path.join(projectRoot, '.ecc', 'memory'),
|
||||
ECC_MEMORY_USER_ROOT: path.join(homeDir, '.ecc', 'memory'),
|
||||
ECC_MEMORY_HARNESS: 'claude',
|
||||
...extraEnv,
|
||||
}).filter(([, value]) => typeof value === 'string')
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTextResult(result) {
|
||||
const text = result.content?.find(item => item.type === 'text')?.text;
|
||||
assert.ok(text, 'MCP result should contain text');
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
async function withClient(fn, options = {}) {
|
||||
const fixture = createFixture(options.env);
|
||||
const child = spawn(process.execPath, [options.server || SERVER], {
|
||||
cwd: fixture.projectRoot,
|
||||
env: fixture.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const pending = new Map();
|
||||
let nextId = 1;
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString('utf8');
|
||||
let newlineIndex = stdout.indexOf('\n');
|
||||
while (newlineIndex >= 0) {
|
||||
const line = stdout.slice(0, newlineIndex);
|
||||
stdout = stdout.slice(newlineIndex + 1);
|
||||
if (line.trim()) {
|
||||
const message = JSON.parse(line);
|
||||
const waiter = pending.get(message.id);
|
||||
if (waiter) {
|
||||
pending.delete(message.id);
|
||||
if (message.error) {
|
||||
waiter.reject(new Error(`${message.error.code}: ${message.error.message}`));
|
||||
} else {
|
||||
waiter.resolve(message.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
newlineIndex = stdout.indexOf('\n');
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
|
||||
function send(message) {
|
||||
child.stdin.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
function request(method, params = {}) {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`Timed out waiting for ${method}. stderr: ${stderr}`));
|
||||
}, 5000);
|
||||
pending.set(id, {
|
||||
resolve: value => {
|
||||
clearTimeout(timeout);
|
||||
resolve(value);
|
||||
},
|
||||
reject: error => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
send({ jsonrpc: '2.0', id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
const initialized = await request('initialize', {
|
||||
protocolVersion: '2025-11-25',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'ecc-memory-test', version: '1.0.0' },
|
||||
});
|
||||
assert.strictEqual(initialized.protocolVersion, '2025-11-25');
|
||||
send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
|
||||
|
||||
const client = {
|
||||
listTools: () => request('tools/list'),
|
||||
callTool: ({ name, arguments: toolArguments }) => request(
|
||||
'tools/call',
|
||||
{ name, arguments: toolArguments }
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
await fn(client, fixture);
|
||||
} finally {
|
||||
child.stdin.end();
|
||||
await new Promise(resolve => {
|
||||
if (child.exitCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill();
|
||||
resolve();
|
||||
}, 2000);
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\n=== Testing ECC memory MCP server ===\n');
|
||||
|
||||
await test('registers the bounded read/write/search/doctor tool surface', async () => {
|
||||
await withClient(async client => {
|
||||
const tools = await client.listTools();
|
||||
assert.deepStrictEqual(
|
||||
tools.tools.map(tool => tool.name).sort(),
|
||||
['memory_doctor', 'memory_read', 'memory_save', 'memory_search']
|
||||
);
|
||||
const save = tools.tools.find(tool => tool.name === 'memory_save');
|
||||
const search = tools.tools.find(tool => tool.name === 'memory_search');
|
||||
assert.ok(save.description.includes('unreviewed'));
|
||||
assert.ok(!JSON.stringify(save.inputSchema).includes('trust'));
|
||||
assert.ok(!JSON.stringify(save.inputSchema).includes('sourceHarness'));
|
||||
assert.ok(!JSON.stringify(search.inputSchema).includes('targetHarness'));
|
||||
assert.strictEqual(save.inputSchema.properties.body.minLength, 1);
|
||||
});
|
||||
});
|
||||
|
||||
await test('starts when the npm bin invokes the server through a symlink', async () => {
|
||||
const binRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-bin-'));
|
||||
const binPath = path.join(binRoot, 'ecc-memory-mcp');
|
||||
fs.symlinkSync(SERVER, binPath);
|
||||
try {
|
||||
await withClient(async client => {
|
||||
const tools = await client.listTools();
|
||||
assert.strictEqual(tools.tools.length, 4);
|
||||
}, { server: binPath });
|
||||
} finally {
|
||||
fs.rmSync(binRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
await test('rejects an oversized partial line and recovers at the next message boundary', async () => {
|
||||
const {
|
||||
MAX_MESSAGE_BYTES,
|
||||
runStdioServer,
|
||||
} = await import(pathToFileURL(SERVER).href);
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
let rawOutput = '';
|
||||
output.on('data', chunk => {
|
||||
rawOutput += chunk.toString('utf8');
|
||||
});
|
||||
runStdioServer({
|
||||
input,
|
||||
output,
|
||||
serviceOptions: { harness: 'claude' },
|
||||
});
|
||||
|
||||
input.write(Buffer.alloc(MAX_MESSAGE_BYTES + 1, 0x78));
|
||||
input.write(`\n${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-11-25',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'bounded-test', version: '1.0.0' },
|
||||
},
|
||||
})}\n`);
|
||||
input.end();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timed out waiting for bounded output.')), 3000);
|
||||
const poll = () => {
|
||||
if (rawOutput.trim().split('\n').length >= 2) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
} else {
|
||||
setImmediate(poll);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
});
|
||||
|
||||
const messages = rawOutput.trim().split('\n').map(line => JSON.parse(line));
|
||||
assert.strictEqual(messages.length, 2);
|
||||
assert.strictEqual(messages[0].error.code, -32700);
|
||||
assert.strictEqual(messages[1].result.protocolVersion, '2025-11-25');
|
||||
});
|
||||
|
||||
await test('shares a saved handoff through MCP search and read', async () => {
|
||||
await withClient(async client => {
|
||||
const savedResult = await client.callTool({
|
||||
name: 'memory_save',
|
||||
arguments: {
|
||||
title: 'Codex to Claude handoff',
|
||||
body: 'The migration is green; review the rollout note.',
|
||||
kind: 'handoff',
|
||||
scope: 'project',
|
||||
targetHarnesses: ['claude'],
|
||||
tags: ['migration'],
|
||||
},
|
||||
});
|
||||
assert.strictEqual(savedResult.isError, undefined);
|
||||
const saved = parseTextResult(savedResult);
|
||||
assert.strictEqual(saved.memory.trust, 'unreviewed');
|
||||
assert.strictEqual(saved.memory.sourceHarness, 'claude');
|
||||
assert.strictEqual(Object.hasOwn(saved.memory, 'body'), false);
|
||||
|
||||
const searchResult = await client.callTool({
|
||||
name: 'memory_search',
|
||||
arguments: {
|
||||
query: 'migration rollout',
|
||||
limit: 5,
|
||||
},
|
||||
});
|
||||
const search = parseTextResult(searchResult);
|
||||
assert.strictEqual(search.results.length, 1);
|
||||
assert.strictEqual(search.results[0].memory.id, saved.memory.id);
|
||||
|
||||
const readResult = await client.callTool({
|
||||
name: 'memory_read',
|
||||
arguments: { id: saved.memory.id },
|
||||
});
|
||||
const read = parseTextResult(readResult);
|
||||
assert.strictEqual(read.memory.body, 'The migration is green; review the rollout note.');
|
||||
|
||||
const doctorResult = await client.callTool({
|
||||
name: 'memory_doctor',
|
||||
arguments: {},
|
||||
});
|
||||
const doctor = parseTextResult(doctorResult);
|
||||
assert.strictEqual(doctor.ok, true);
|
||||
assert.strictEqual(doctor.memoryCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
await test('rejects caller identity spoofing and hides other-harness memories', async () => {
|
||||
await withClient(async client => {
|
||||
await assert.rejects(
|
||||
() => client.callTool({
|
||||
name: 'memory_save',
|
||||
arguments: {
|
||||
title: 'Spoofed source',
|
||||
body: 'This must not be accepted.',
|
||||
sourceHarness: 'hermes',
|
||||
},
|
||||
}),
|
||||
/-32602/
|
||||
);
|
||||
await assert.rejects(
|
||||
() => client.callTool({
|
||||
name: 'memory_search',
|
||||
arguments: {
|
||||
query: '',
|
||||
targetHarness: 'hermes',
|
||||
},
|
||||
}),
|
||||
/-32602/
|
||||
);
|
||||
|
||||
const savedResult = await client.callTool({
|
||||
name: 'memory_save',
|
||||
arguments: {
|
||||
title: 'Hermes-only handoff',
|
||||
body: 'Only Hermes should receive this context.',
|
||||
kind: 'handoff',
|
||||
targetHarnesses: ['hermes'],
|
||||
},
|
||||
});
|
||||
const saved = parseTextResult(savedResult);
|
||||
assert.strictEqual(saved.memory.sourceHarness, 'claude');
|
||||
|
||||
const searchResult = await client.callTool({
|
||||
name: 'memory_search',
|
||||
arguments: { query: 'Hermes-only' },
|
||||
});
|
||||
assert.strictEqual(parseTextResult(searchResult).results.length, 0);
|
||||
|
||||
const readResult = await client.callTool({
|
||||
name: 'memory_read',
|
||||
arguments: { id: saved.memory.id },
|
||||
});
|
||||
assert.strictEqual(readResult.isError, true);
|
||||
assert.strictEqual(parseTextResult(readResult).error.code, 'MEMORY_READ_FAILED');
|
||||
|
||||
const doctor = parseTextResult(await client.callTool({
|
||||
name: 'memory_doctor',
|
||||
arguments: {},
|
||||
}));
|
||||
assert.strictEqual(doctor.memoryCount, 0);
|
||||
assert.strictEqual(Object.hasOwn(doctor, 'brokenLinks'), false);
|
||||
assert.strictEqual(Object.hasOwn(doctor, 'invalidFiles'), false);
|
||||
assert.strictEqual(JSON.stringify(doctor).includes(saved.memory.id), false);
|
||||
});
|
||||
});
|
||||
|
||||
await test('filters harness-visible backlinks before applying the response cap', async () => {
|
||||
await withClient(async (client, fixture) => {
|
||||
const roots = resolveVaultRoots({
|
||||
cwd: fixture.projectRoot,
|
||||
env: fixture.env,
|
||||
});
|
||||
const saveWithId = (input, id) => saveMemory(input, {
|
||||
roots,
|
||||
now: () => '2026-07-26T20:00:00.000Z',
|
||||
idFactory: () => id,
|
||||
});
|
||||
const targetId = 'mem_backlink_target';
|
||||
saveWithId({
|
||||
title: 'Backlink target',
|
||||
body: 'Visible target body.',
|
||||
targetHarnesses: ['claude'],
|
||||
}, targetId);
|
||||
|
||||
for (let index = 0; index < MAX_RESULTS; index += 1) {
|
||||
saveWithId({
|
||||
title: `Hidden backlink ${index}`,
|
||||
body: 'Only Hermes may see this backlink.',
|
||||
targetHarnesses: ['hermes'],
|
||||
links: [targetId],
|
||||
}, `mem_backlink_hidden_${String(index).padStart(3, '0')}`);
|
||||
}
|
||||
saveWithId({
|
||||
title: 'Visible backlink',
|
||||
body: 'Claude must still receive this backlink.',
|
||||
targetHarnesses: ['claude'],
|
||||
links: [targetId],
|
||||
}, 'mem_backlink_visible_zzz');
|
||||
|
||||
const read = parseTextResult(await client.callTool({
|
||||
name: 'memory_read',
|
||||
arguments: { id: targetId },
|
||||
}));
|
||||
assert.deepStrictEqual(
|
||||
read.backlinks.map(memory => memory.id),
|
||||
['mem_backlink_visible_zzz']
|
||||
);
|
||||
assert.strictEqual(read.backlinksTruncated, false);
|
||||
});
|
||||
});
|
||||
|
||||
await test('denies user scope unless the server explicitly grants it', async () => {
|
||||
await withClient(async client => {
|
||||
await assert.rejects(
|
||||
() => client.callTool({
|
||||
name: 'memory_save',
|
||||
arguments: {
|
||||
title: 'Private preference',
|
||||
body: 'Keep this in the user vault.',
|
||||
scope: 'user',
|
||||
},
|
||||
}),
|
||||
/user memory scope is disabled/
|
||||
);
|
||||
await assert.rejects(
|
||||
() => client.callTool({
|
||||
name: 'memory_search',
|
||||
arguments: { scopes: ['user'] },
|
||||
}),
|
||||
/user memory scope is disabled/
|
||||
);
|
||||
await assert.rejects(
|
||||
() => client.callTool({
|
||||
name: 'memory_read',
|
||||
arguments: {
|
||||
id: 'mem_20260726_user_scope_denied',
|
||||
scope: 'user',
|
||||
},
|
||||
}),
|
||||
/user memory scope is disabled/
|
||||
);
|
||||
});
|
||||
|
||||
await withClient(async client => {
|
||||
const savedResult = await client.callTool({
|
||||
name: 'memory_save',
|
||||
arguments: {
|
||||
title: 'Private preference',
|
||||
body: 'Keep this in the user vault.',
|
||||
scope: 'user',
|
||||
},
|
||||
});
|
||||
const saved = parseTextResult(savedResult);
|
||||
assert.strictEqual(saved.memory.scope, 'user');
|
||||
|
||||
const defaultSearch = parseTextResult(await client.callTool({
|
||||
name: 'memory_search',
|
||||
arguments: { query: 'Private preference' },
|
||||
}));
|
||||
assert.strictEqual(defaultSearch.results.length, 0);
|
||||
|
||||
const userSearch = parseTextResult(await client.callTool({
|
||||
name: 'memory_search',
|
||||
arguments: {
|
||||
query: 'Private preference',
|
||||
scopes: ['user'],
|
||||
},
|
||||
}));
|
||||
assert.strictEqual(userSearch.results[0].memory.id, saved.memory.id);
|
||||
|
||||
const userRead = parseTextResult(await client.callTool({
|
||||
name: 'memory_read',
|
||||
arguments: {
|
||||
id: saved.memory.id,
|
||||
scope: 'user',
|
||||
},
|
||||
}));
|
||||
assert.strictEqual(userRead.memory.id, saved.memory.id);
|
||||
}, { env: { ECC_MEMORY_ALLOW_USER_SCOPE: '1' } });
|
||||
});
|
||||
|
||||
await test('requires server identity and strictly validates JSON-RPC envelopes', async () => {
|
||||
const { createMemoryMcpService } = await import(pathToFileURL(SERVER).href);
|
||||
assert.throws(
|
||||
() => createMemoryMcpService({ env: {} }),
|
||||
/ECC_MEMORY_HARNESS/
|
||||
);
|
||||
|
||||
const fixture = createFixture({ ECC_MEMORY_HARNESS: undefined });
|
||||
try {
|
||||
const started = spawnSync(process.execPath, [SERVER], {
|
||||
cwd: fixture.projectRoot,
|
||||
env: fixture.env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.strictEqual(started.error, undefined);
|
||||
assert.strictEqual(started.status, 1);
|
||||
assert.match(started.stderr, /ECC_MEMORY_HARNESS/);
|
||||
assert.ok(!started.stderr.includes('\n at '));
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const service = createMemoryMcpService({ harness: 'claude' });
|
||||
for (const id of [null, false, {}, [], 1.5, Number.MAX_SAFE_INTEGER + 1, '']) {
|
||||
const response = await service.handle({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method: 'initialize',
|
||||
params: {},
|
||||
});
|
||||
assert.strictEqual(response.id, null);
|
||||
assert.strictEqual(response.error.code, -32600);
|
||||
}
|
||||
|
||||
const initialized = await service.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 0,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-11-25',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'strict-test', version: '1.0.0' },
|
||||
},
|
||||
});
|
||||
assert.strictEqual(initialized.id, 0);
|
||||
await service.handle({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
params: {},
|
||||
});
|
||||
|
||||
for (const toolArguments of [null, false, 0, '', []]) {
|
||||
const response = await service.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: `args-${String(toolArguments)}`,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'memory_doctor',
|
||||
arguments: toolArguments,
|
||||
},
|
||||
});
|
||||
assert.strictEqual(response.error.code, -32602);
|
||||
}
|
||||
const invalidParams = await service.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: [],
|
||||
});
|
||||
assert.strictEqual(invalidParams.error.code, -32600);
|
||||
});
|
||||
|
||||
await test('bounds queued transport work under a single-chunk request flood', async () => {
|
||||
const {
|
||||
MAX_PENDING_MESSAGES,
|
||||
runStdioServer,
|
||||
} = await import(pathToFileURL(SERVER).href);
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
let rawOutput = '';
|
||||
output.on('data', chunk => {
|
||||
rawOutput += chunk.toString('utf8');
|
||||
});
|
||||
runStdioServer({
|
||||
input,
|
||||
output,
|
||||
serviceOptions: { harness: 'claude' },
|
||||
});
|
||||
|
||||
const requests = [
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 'init',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-11-25',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'flood-test', version: '1.0.0' },
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
params: {},
|
||||
},
|
||||
...Array.from({ length: MAX_PENDING_MESSAGES * 4 }, (_, index) => ({
|
||||
jsonrpc: '2.0',
|
||||
id: `ping-${index}`,
|
||||
method: 'ping',
|
||||
params: {},
|
||||
})),
|
||||
];
|
||||
input.end(`${requests.map(JSON.stringify).join('\n')}\n`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error('Timed out waiting for queue-limit response.')),
|
||||
3000
|
||||
);
|
||||
const poll = () => {
|
||||
if (rawOutput.includes('queue limit exceeded')) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
} else {
|
||||
setImmediate(poll);
|
||||
}
|
||||
};
|
||||
poll();
|
||||
});
|
||||
|
||||
const messages = rawOutput.trim().split('\n').map(line => JSON.parse(line));
|
||||
assert.ok(messages.some(message => message.error?.code === -32000));
|
||||
assert.ok(messages.length <= MAX_PENDING_MESSAGES + 2);
|
||||
});
|
||||
|
||||
await test('bounds serialized tool responses before writing to stdout', async () => {
|
||||
const {
|
||||
MAX_RESPONSE_BYTES,
|
||||
textResult,
|
||||
} = await import(pathToFileURL(SERVER).href);
|
||||
assert.throws(
|
||||
() => textResult({ body: 'x'.repeat(MAX_RESPONSE_BYTES + 1) }),
|
||||
/bounded output limit/
|
||||
);
|
||||
});
|
||||
|
||||
await test('returns a structured tool error without a stack trace for secret-bearing writes', async () => {
|
||||
await withClient(async client => {
|
||||
await assert.rejects(
|
||||
() => client.callTool({
|
||||
name: 'memory_save',
|
||||
arguments: {
|
||||
title: 'Empty body',
|
||||
body: '',
|
||||
},
|
||||
}),
|
||||
/-32602/
|
||||
);
|
||||
const secret = `ghp_${'A1'.repeat(12)}`;
|
||||
const result = await client.callTool({
|
||||
name: 'memory_save',
|
||||
arguments: {
|
||||
title: 'Do not persist this',
|
||||
body: `credential ${secret}`,
|
||||
},
|
||||
});
|
||||
assert.strictEqual(result.isError, true);
|
||||
const error = parseTextResult(result);
|
||||
assert.strictEqual(error.error.code, 'MEMORY_WRITE_REJECTED');
|
||||
assert.ok(error.error.message.includes('suspected secret'));
|
||||
assert.ok(!JSON.stringify(error).includes(secret));
|
||||
assert.ok(!JSON.stringify(error).includes('\n at '));
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,478 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const MEMORY_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'memory.js');
|
||||
const ECC_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js');
|
||||
const {
|
||||
readBoundedStdin,
|
||||
runCommand,
|
||||
sanitizeTerminalText,
|
||||
} = require(MEMORY_SCRIPT);
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` ${error.stack || error.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function createFixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-cli-'));
|
||||
const projectRoot = path.join(root, 'project');
|
||||
const homeDir = path.join(root, 'home');
|
||||
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
return {
|
||||
root,
|
||||
projectRoot,
|
||||
homeDir,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
ECC_MEMORY_PROJECT_ROOT: path.join(projectRoot, '.ecc', 'memory'),
|
||||
ECC_MEMORY_USER_ROOT: path.join(homeDir, '.ecc', 'memory'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function run(script, args, fixture, options = {}) {
|
||||
return spawnSync(process.execPath, [script, ...args], {
|
||||
cwd: fixture.projectRoot,
|
||||
env: { ...fixture.env, ...(options.env || {}) },
|
||||
input: options.input,
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
function json(result) {
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
console.log('\n=== Testing ecc memory CLI ===\n');
|
||||
|
||||
test('keeps runCommand focused on dispatch under the function-size guideline', () => {
|
||||
const lineCount = runCommand.toString().split('\n').length;
|
||||
assert.ok(lineCount < 50, `runCommand is ${lineCount} lines; expected fewer than 50`);
|
||||
});
|
||||
|
||||
test('shows memory command help directly and through the ecc router', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const direct = run(MEMORY_SCRIPT, ['--help'], fixture);
|
||||
assert.strictEqual(direct.status, 0, direct.stderr);
|
||||
assert.ok(direct.stdout.includes('ecc memory save'));
|
||||
assert.ok(direct.stdout.includes('ecc-memory-mcp'));
|
||||
|
||||
const routed = run(ECC_SCRIPT, ['memory', '--help'], fixture);
|
||||
assert.strictEqual(routed.status, 0, routed.stderr);
|
||||
assert.ok(routed.stdout.includes('ecc memory search'));
|
||||
assert.ok(routed.stdout.includes('Default recall scopes: project and team'));
|
||||
assert.ok(routed.stdout.includes('user scope must be requested explicitly'));
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('routes stdin through ecc memory without dropping the body', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const saved = json(run(ECC_SCRIPT, [
|
||||
'memory',
|
||||
'save',
|
||||
'--title', 'Routed stdin',
|
||||
'--stdin',
|
||||
'--json',
|
||||
], fixture, { input: 'The router must preserve this exact body.\n' }));
|
||||
|
||||
assert.strictEqual(Object.hasOwn(saved.memory, 'body'), false);
|
||||
const read = json(run(
|
||||
MEMORY_SCRIPT,
|
||||
['read', saved.memory.id, '--json'],
|
||||
fixture
|
||||
));
|
||||
assert.strictEqual(read.memory.body, 'The router must preserve this exact body.');
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('retries transient stdin EAGAIN without busy-spinning and preserves byte bounds', () => {
|
||||
const originalReadSync = fs.readSync;
|
||||
let readCalls = 0;
|
||||
let waitCalls = 0;
|
||||
try {
|
||||
fs.readSync = (_descriptor, buffer) => {
|
||||
readCalls += 1;
|
||||
if (readCalls <= 2) {
|
||||
const error = new Error('temporarily unavailable');
|
||||
error.code = 'EAGAIN';
|
||||
throw error;
|
||||
}
|
||||
if (readCalls === 3) {
|
||||
buffer.write('ready');
|
||||
return 5;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
assert.strictEqual(readBoundedStdin(8, {
|
||||
retryDelayMs: 1,
|
||||
maxRetryWaitMs: 4,
|
||||
wait: () => {
|
||||
waitCalls += 1;
|
||||
},
|
||||
}), 'ready');
|
||||
assert.strictEqual(readCalls, 4);
|
||||
assert.strictEqual(waitCalls, 2);
|
||||
} finally {
|
||||
fs.readSync = originalReadSync;
|
||||
}
|
||||
});
|
||||
|
||||
test('bounds persistent stdin EAGAIN retries instead of waiting forever', () => {
|
||||
const originalReadSync = fs.readSync;
|
||||
let readCalls = 0;
|
||||
let waitCalls = 0;
|
||||
try {
|
||||
fs.readSync = () => {
|
||||
readCalls += 1;
|
||||
const error = new Error('temporarily unavailable');
|
||||
error.code = 'EAGAIN';
|
||||
throw error;
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => readBoundedStdin(8, {
|
||||
retryDelayMs: 1,
|
||||
maxRetryWaitMs: 3,
|
||||
wait: () => {
|
||||
waitCalls += 1;
|
||||
},
|
||||
}),
|
||||
/standard input remained unavailable/i
|
||||
);
|
||||
assert.strictEqual(readCalls, 4);
|
||||
assert.strictEqual(waitCalls, 3);
|
||||
} finally {
|
||||
fs.readSync = originalReadSync;
|
||||
}
|
||||
});
|
||||
|
||||
test('initializes selected scopes and reports their roots as JSON', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const payload = json(run(
|
||||
MEMORY_SCRIPT,
|
||||
['init', '--scope', 'project', '--scope', 'team', '--json'],
|
||||
fixture
|
||||
));
|
||||
assert.strictEqual(payload.schemaVersion, 'ecc.memory.init.v1');
|
||||
assert.deepStrictEqual(payload.scopes, ['project', 'team']);
|
||||
assert.ok(fs.statSync(path.join(payload.roots.project, 'handoffs')).isDirectory());
|
||||
assert.ok(fs.statSync(path.join(payload.roots.team, 'decisions')).isDirectory());
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('saves and reads a targeted handoff without a harness-specific inbox', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const saved = json(run(MEMORY_SCRIPT, [
|
||||
'handoff',
|
||||
'--from', 'codex',
|
||||
'--target', 'claude',
|
||||
'--target', 'hermes',
|
||||
'--title', 'Finish auth migration',
|
||||
'--stdin',
|
||||
'--tag', 'auth',
|
||||
'--json',
|
||||
], fixture, { input: 'Token rotation tests pass.' }));
|
||||
|
||||
assert.strictEqual(saved.schemaVersion, 'ecc.memory.write.v1');
|
||||
assert.strictEqual(saved.memory.kind, 'handoff');
|
||||
assert.strictEqual(saved.memory.trust, 'unreviewed');
|
||||
assert.deepStrictEqual(saved.memory.targetHarnesses, ['claude', 'hermes']);
|
||||
assert.strictEqual(saved.path, `project:handoffs/${saved.memory.id}.md`);
|
||||
assert.strictEqual(Object.hasOwn(saved.memory, 'body'), false);
|
||||
|
||||
const read = json(run(
|
||||
MEMORY_SCRIPT,
|
||||
['read', saved.memory.id, '--json'],
|
||||
fixture
|
||||
));
|
||||
assert.strictEqual(read.schemaVersion, 'ecc.memory.read.v1');
|
||||
assert.strictEqual(read.memory.body, 'Token rotation tests pass.');
|
||||
assert.deepStrictEqual(read.backlinks, []);
|
||||
|
||||
const human = run(MEMORY_SCRIPT, [
|
||||
'save',
|
||||
'--title', 'Relative acknowledgement',
|
||||
'--stdin',
|
||||
], fixture, { input: 'Keep local paths out of acknowledgements.' });
|
||||
assert.strictEqual(human.status, 0, human.stderr);
|
||||
assert.ok(human.stdout.includes('Path: project:notes/'));
|
||||
assert.strictEqual(human.stdout.includes(fixture.projectRoot), false);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('read uses default recall scopes and honors an explicit user scope', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const saved = json(run(MEMORY_SCRIPT, [
|
||||
'save',
|
||||
'--title', 'Private preference',
|
||||
'--scope', 'user',
|
||||
'--stdin',
|
||||
'--json',
|
||||
], fixture, { input: 'Prefer compact output.' }));
|
||||
|
||||
const defaultRead = run(
|
||||
MEMORY_SCRIPT,
|
||||
['read', saved.memory.id, '--json'],
|
||||
fixture
|
||||
);
|
||||
assert.notStrictEqual(defaultRead.status, 0);
|
||||
assert.ok(defaultRead.stderr.includes('was not found'));
|
||||
|
||||
const explicitRead = json(run(
|
||||
MEMORY_SCRIPT,
|
||||
['read', saved.memory.id, '--scope', 'user', '--json'],
|
||||
fixture
|
||||
));
|
||||
assert.strictEqual(explicitRead.memory.id, saved.memory.id);
|
||||
assert.strictEqual(explicitRead.memory.scope, 'user');
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts body content over stdin and finds it through bounded JSON search', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const saved = json(run(MEMORY_SCRIPT, [
|
||||
'save',
|
||||
'--title', 'Database decision',
|
||||
'--kind', 'decision',
|
||||
'--scope', 'team',
|
||||
'--source-harness', 'claude',
|
||||
'--target', 'all',
|
||||
'--tag', 'sqlite',
|
||||
'--stdin',
|
||||
'--json',
|
||||
], fixture, { input: 'Use SQLite as the durable local store.\n' }));
|
||||
|
||||
assert.strictEqual(saved.memory.scope, 'team');
|
||||
assert.strictEqual(Object.hasOwn(saved.memory, 'body'), false);
|
||||
|
||||
const search = json(run(MEMORY_SCRIPT, [
|
||||
'search',
|
||||
'sqlite durable',
|
||||
'--scope', 'team',
|
||||
'--target-harness', 'codex',
|
||||
'--limit', '5',
|
||||
'--json',
|
||||
], fixture));
|
||||
assert.strictEqual(search.schemaVersion, 'ecc.memory.search.v1');
|
||||
assert.strictEqual(search.results.length, 1);
|
||||
assert.strictEqual(search.results[0].memory.id, saved.memory.id);
|
||||
assert.ok(search.results[0].excerpt.includes('SQLite'));
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('doctor is machine-readable and clean for a valid vault', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
json(run(MEMORY_SCRIPT, [
|
||||
'save',
|
||||
'--title', 'Valid note',
|
||||
'--stdin',
|
||||
'--json',
|
||||
], fixture, { input: 'No broken links.' }));
|
||||
const report = json(run(MEMORY_SCRIPT, ['doctor', '--json'], fixture));
|
||||
assert.strictEqual(report.schemaVersion, 'ecc.memory.doctor.v1');
|
||||
assert.strictEqual(report.ok, true);
|
||||
assert.strictEqual(report.memoryCount, 1);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('doctor honors an explicit user scope without recalling it by default', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
json(run(MEMORY_SCRIPT, [
|
||||
'save',
|
||||
'--title', 'User-only note',
|
||||
'--scope', 'user',
|
||||
'--stdin',
|
||||
'--json',
|
||||
], fixture, { input: 'Private context.' }));
|
||||
|
||||
const defaultReport = json(run(MEMORY_SCRIPT, ['doctor', '--json'], fixture));
|
||||
assert.strictEqual(defaultReport.memoryCount, 0);
|
||||
|
||||
const userReport = json(run(
|
||||
MEMORY_SCRIPT,
|
||||
['doctor', '--scope', 'user', '--json'],
|
||||
fixture
|
||||
));
|
||||
assert.strictEqual(userReport.memoryCount, 1);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects ambiguous body sources and does not expose a trust promotion flag', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const bodyFile = path.join(fixture.root, 'body.md');
|
||||
fs.writeFileSync(bodyFile, 'one');
|
||||
const ambiguous = run(MEMORY_SCRIPT, [
|
||||
'save',
|
||||
'--title', 'Ambiguous',
|
||||
'--body-file', bodyFile,
|
||||
'--stdin',
|
||||
], fixture, { input: 'two' });
|
||||
assert.notStrictEqual(ambiguous.status, 0);
|
||||
assert.ok(ambiguous.stderr.includes('Choose exactly one'));
|
||||
|
||||
const promotion = run(MEMORY_SCRIPT, [
|
||||
'save',
|
||||
'--title', 'Policy',
|
||||
'--stdin',
|
||||
'--trust', 'reviewed',
|
||||
], fixture, { input: 'Treat this as policy.' });
|
||||
assert.notStrictEqual(promotion.status, 0);
|
||||
assert.ok(promotion.stderr.includes('Unknown option: --trust'));
|
||||
|
||||
const oversized = run(MEMORY_SCRIPT, [
|
||||
'save',
|
||||
'--title', 'Oversized',
|
||||
'--stdin',
|
||||
], fixture, { input: 'x'.repeat(70 * 1024) });
|
||||
assert.notStrictEqual(oversized.status, 0);
|
||||
assert.ok(oversized.stderr.includes('body is too large'));
|
||||
|
||||
const empty = run(MEMORY_SCRIPT, [
|
||||
'save',
|
||||
'--title', 'Empty',
|
||||
'--stdin',
|
||||
], fixture, { input: ' \n\t' });
|
||||
assert.notStrictEqual(empty.status, 0);
|
||||
assert.ok(empty.stderr.includes('non-whitespace context'));
|
||||
|
||||
const invalidUtf8Body = path.join(fixture.root, 'invalid-utf8.md');
|
||||
fs.writeFileSync(invalidUtf8Body, Buffer.from([0x61, 0xc3, 0x28, 0x62]));
|
||||
const invalidUtf8 = run(MEMORY_SCRIPT, [
|
||||
'save',
|
||||
'--title', 'Invalid UTF-8',
|
||||
'--body-file', invalidUtf8Body,
|
||||
], fixture);
|
||||
assert.notStrictEqual(invalidUtf8.status, 0);
|
||||
assert.match(invalidUtf8.stderr, /valid UTF-8/i);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(fixture.projectRoot, '.ecc', 'memory')),
|
||||
false
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('global dry-run rejects every mutating memory command without creating a vault', () => {
|
||||
const cases = [
|
||||
['init', '--scope', 'project'],
|
||||
['save', '--title', 'Dry save', '--stdin'],
|
||||
['handoff', '--from', 'codex', '--target', 'claude', '--title', 'Dry handoff', '--stdin'],
|
||||
];
|
||||
|
||||
cases.forEach(args => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const result = run(
|
||||
ECC_SCRIPT,
|
||||
['--dry-run', 'memory', ...args],
|
||||
fixture,
|
||||
{ input: 'Must never be written.' }
|
||||
);
|
||||
assert.notStrictEqual(result.status, 0, `${args[0]} unexpectedly succeeded`);
|
||||
assert.ok(result.stderr.toLowerCase().includes('dry-run'), result.stderr);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(fixture.projectRoot, '.ecc', 'memory')),
|
||||
false,
|
||||
`${args[0]} created project memory state`
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(fixture.homeDir, '.ecc', 'memory')),
|
||||
false,
|
||||
`${args[0]} created user memory state`
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('human terminal rendering strips ANSI, OSC, C0/C1, and bidi controls', () => {
|
||||
const hostile = [
|
||||
'safe',
|
||||
'\u001b[31mred\u001b[0m',
|
||||
'\u001b]8;;https://example.test\u0007link\u001b]8;;\u0007',
|
||||
'\u0001c0',
|
||||
'\rrewritten',
|
||||
'\u0085c1',
|
||||
'\u202ebidi',
|
||||
].join(' ');
|
||||
const rendered = sanitizeTerminalText(hostile);
|
||||
|
||||
assert.ok(rendered.includes('safe'));
|
||||
assert.ok(rendered.includes('red'));
|
||||
assert.ok(rendered.includes('link'));
|
||||
assert.ok(rendered.includes('c0'));
|
||||
assert.ok(rendered.includes('rewritten'));
|
||||
assert.ok(rendered.includes('c1'));
|
||||
assert.ok(rendered.includes('bidi'));
|
||||
['\u001b', '\u0001', '\u0007', '\r', '\u0085', '\u202e']
|
||||
.forEach(control => assert.ok(!rendered.includes(control)));
|
||||
});
|
||||
|
||||
test('JSON output preserves data without applying terminal rendering rules', () => {
|
||||
const hostile = 'plain\u001b[31mred\u001b[0m\u202e';
|
||||
const script = [
|
||||
`const { writeJson } = require(${JSON.stringify(MEMORY_SCRIPT)});`,
|
||||
`writeJson({ value: ${JSON.stringify(hostile)} });`,
|
||||
].join('');
|
||||
const result = spawnSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(JSON.parse(result.stdout).value, hostile);
|
||||
});
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -58,6 +58,8 @@ function buildExpectedPublishPaths(repoRoot) {
|
||||
"scripts/ito.js",
|
||||
"scripts/list-installed.js",
|
||||
"scripts/loop-status.js",
|
||||
"scripts/memory.js",
|
||||
"scripts/memory-mcp.mjs",
|
||||
"scripts/observability-readiness.js",
|
||||
"scripts/plan-canvas.js",
|
||||
"scripts/operator-readiness-dashboard.js",
|
||||
@@ -91,6 +93,7 @@ function buildExpectedPublishPaths(repoRoot) {
|
||||
"assets/images/community",
|
||||
"docs/CODEX-NAVIGATION-GUIDE.md",
|
||||
"docs/COMMAND-AGENT-MAP.md",
|
||||
"docs/design/ecc-memory-vault.md",
|
||||
"assets/images/sponsors",
|
||||
]
|
||||
const exclusionPaths = [
|
||||
@@ -146,6 +149,10 @@ function main() {
|
||||
"scripts/consult.js",
|
||||
"scripts/control-pane.js",
|
||||
"scripts/ito.js",
|
||||
"scripts/memory.js",
|
||||
"scripts/memory-mcp.mjs",
|
||||
"scripts/lib/memory-vault-format.js",
|
||||
"scripts/lib/memory-vault.js",
|
||||
"scripts/discussion-audit.js",
|
||||
"scripts/operator-readiness-dashboard.js",
|
||||
"scripts/preview-pack-smoke.js",
|
||||
@@ -160,6 +167,9 @@ function main() {
|
||||
".claude-plugin/plugin.json",
|
||||
".github/PULL_REQUEST_TEMPLATE.md",
|
||||
".codex-plugin/plugin.json",
|
||||
".agents/skills/unified-memory/SKILL.md",
|
||||
".agents/skills/unified-memory/agents/openai.yaml",
|
||||
".cursor/skills/unified-memory/SKILL.md",
|
||||
"COMMANDS-QUICK-REF.md",
|
||||
"CONTRIBUTING.md",
|
||||
"plugins/ecc/.codex-plugin/plugin.json",
|
||||
@@ -169,8 +179,11 @@ function main() {
|
||||
"assets/images/community/heart.svg",
|
||||
"docs/CODEX-NAVIGATION-GUIDE.md",
|
||||
"docs/COMMAND-AGENT-MAP.md",
|
||||
"docs/design/ecc-memory-vault.md",
|
||||
"schemas/install-state.schema.json",
|
||||
"schemas/memory.schema.json",
|
||||
"skills/backend-patterns/SKILL.md",
|
||||
"skills/unified-memory/SKILL.md",
|
||||
]) {
|
||||
assert.ok(
|
||||
packagedPaths.has(requiredPath),
|
||||
|
||||
Reference in New Issue
Block a user