mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-30 22:13:28 +08:00
Compare commits
4 Commits
fix/insait
...
fix/ecc2-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaaf52fb1e | ||
|
|
33edfd3bb3 | ||
|
|
f92dc544c4 | ||
|
|
1c2d5dd389 |
@@ -98,6 +98,16 @@ process.stdin.on('end', () => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The monitor only uses 0 (pass) and 2 (block). Other statuses usually
|
||||||
|
// mean Python launcher/dependency/runtime failure, so keep the hook fail-open.
|
||||||
|
if (result.status !== 0 && result.status !== 2) {
|
||||||
|
const detail = (result.stderr || result.stdout || '').trim();
|
||||||
|
const suffix = detail ? `: ${detail}` : '';
|
||||||
|
process.stderr.write(`[InsAIts] Security monitor exited with status ${result.status}${suffix}\n`);
|
||||||
|
process.stdout.write(raw);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.stdout) {
|
if (result.stdout) {
|
||||||
process.stdout.write(result.stdout);
|
process.stdout.write(result.stdout);
|
||||||
} else if (result.status === 0) {
|
} else if (result.status === 0) {
|
||||||
|
|||||||
@@ -166,6 +166,29 @@ function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('enabled monitor unexpected failure fails open with warning and raw stdin', () => {
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
try {
|
||||||
|
writeFakePython(path.join(tempDir, 'bin'));
|
||||||
|
|
||||||
|
const result = run({
|
||||||
|
input: 'raw-input',
|
||||||
|
env: {
|
||||||
|
ECC_ENABLE_INSAITS: '1',
|
||||||
|
FAKE_INSAITS_MODE: 'error',
|
||||||
|
PATH: path.join(tempDir, 'bin'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.status, 0);
|
||||||
|
assert.strictEqual(result.stdout, 'raw-input');
|
||||||
|
assert.ok(result.stderr.includes('Security monitor exited with status 1'));
|
||||||
|
assert.ok(result.stderr.includes('spawned but failed'));
|
||||||
|
} finally {
|
||||||
|
cleanup(tempDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (test('missing Python fails open with warning and raw stdin', () => {
|
if (test('missing Python fails open with warning and raw stdin', () => {
|
||||||
const result = run({
|
const result = run({
|
||||||
input: 'raw-input',
|
input: 'raw-input',
|
||||||
@@ -177,7 +200,10 @@ function runTests() {
|
|||||||
|
|
||||||
assert.strictEqual(result.status, 0);
|
assert.strictEqual(result.status, 0);
|
||||||
assert.strictEqual(result.stdout, 'raw-input');
|
assert.strictEqual(result.stdout, 'raw-input');
|
||||||
assert.ok(result.stderr.includes('python3/python not found'));
|
assert.ok(
|
||||||
|
result.stderr.includes('python3/python not found')
|
||||||
|
|| result.stderr.includes('Security monitor exited with status')
|
||||||
|
);
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
|||||||
@@ -61,15 +61,29 @@ function createCommandConfig(scriptPath) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function runHook(input, env = {}) {
|
function buildHookEnv(env = {}) {
|
||||||
|
const merged = {
|
||||||
|
...process.env,
|
||||||
|
ECC_HOOK_PROFILE: 'standard'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
delete merged[key];
|
||||||
|
} else {
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runHook(input, env = {}, options = {}) {
|
||||||
const result = spawnSync('node', [script], {
|
const result = spawnSync('node', [script], {
|
||||||
input: JSON.stringify(input),
|
input: JSON.stringify(input),
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
env: {
|
cwd: options.cwd || process.cwd(),
|
||||||
...process.env,
|
env: buildHookEnv(env),
|
||||||
ECC_HOOK_PROFILE: 'standard',
|
|
||||||
...env
|
|
||||||
},
|
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
stdio: ['pipe', 'pipe', 'pipe']
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
});
|
});
|
||||||
@@ -81,15 +95,12 @@ function runHook(input, env = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function runRawHook(rawInput, env = {}) {
|
function runRawHook(rawInput, env = {}, options = {}) {
|
||||||
const result = spawnSync('node', [script], {
|
const result = spawnSync('node', [script], {
|
||||||
input: rawInput,
|
input: rawInput,
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
env: {
|
cwd: options.cwd || process.cwd(),
|
||||||
...process.env,
|
env: buildHookEnv(env),
|
||||||
ECC_HOOK_PROFILE: 'standard',
|
|
||||||
...env
|
|
||||||
},
|
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
stdio: ['pipe', 'pipe', 'pipe']
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
});
|
});
|
||||||
@@ -173,6 +184,192 @@ async function runTests() {
|
|||||||
assert.ok(result.stderr.includes('Hook input exceeded 512 bytes'), `Expected size warning, got: ${result.stderr}`);
|
assert.ok(result.stderr.includes('Hook input exceeded 512 bytes'), `Expected size warning, got: ${result.stderr}`);
|
||||||
assert.ok(/blocking search/i.test(result.stderr), `Expected blocking message, got: ${result.stderr}`);
|
assert.ok(/blocking search/i.test(result.stderr), `Expected blocking message, got: ${result.stderr}`);
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('allows truncated MCP hook input when fail-open mode is enabled', () => {
|
||||||
|
const rawInput = JSON.stringify({ tool_name: 'mcp__flaky__search', tool_input: {} });
|
||||||
|
const result = runRawHook(rawInput, {
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||||
|
ECC_HOOK_INPUT_TRUNCATED: 'true',
|
||||||
|
ECC_HOOK_INPUT_MAX_BYTES: '256',
|
||||||
|
ECC_MCP_HEALTH_FAIL_OPEN: 'yes'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0, 'Expected fail-open mode to allow truncated MCP input');
|
||||||
|
assert.strictEqual(result.stdout, rawInput, 'Expected raw input passthrough on stdout');
|
||||||
|
assert.ok(result.stderr.includes('Hook input exceeded 256 bytes'), `Expected size warning, got: ${result.stderr}`);
|
||||||
|
assert.ok(/fail-open mode is enabled/i.test(result.stderr), `Expected fail-open log, got: ${result.stderr}`);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (await asyncTest('uses default cwd config path and default home state path', async () => {
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
const homeDir = path.join(tempDir, 'home');
|
||||||
|
const configDir = path.join(tempDir, '.claude');
|
||||||
|
const configPath = path.join(configDir, 'settings.json');
|
||||||
|
const expectedStatePath = path.join(homeDir, '.claude', 'mcp-health-cache.json');
|
||||||
|
const serverScript = path.join(tempDir, 'default-path-server.js');
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.mkdirSync(homeDir, { recursive: true });
|
||||||
|
fs.writeFileSync(serverScript, "setInterval(() => {}, 1000);\n");
|
||||||
|
writeConfig(configPath, {
|
||||||
|
mcpServers: {
|
||||||
|
cwddefault: createCommandConfig(serverScript)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = { tool_name: 'mcp__cwddefault__list', tool_input: {} };
|
||||||
|
const result = runHook(
|
||||||
|
input,
|
||||||
|
{
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||||
|
ECC_MCP_CONFIG_PATH: null,
|
||||||
|
ECC_MCP_HEALTH_STATE_PATH: null,
|
||||||
|
ECC_MCP_HEALTH_TIMEOUT_MS: '100',
|
||||||
|
HOME: homeDir,
|
||||||
|
USERPROFILE: homeDir
|
||||||
|
},
|
||||||
|
{ cwd: tempDir }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0, `Expected default-path server to pass, got ${result.code}: ${result.stderr}`);
|
||||||
|
assert.strictEqual(result.stdout.trim(), JSON.stringify(input), 'Expected original JSON on stdout');
|
||||||
|
|
||||||
|
const state = readState(expectedStatePath);
|
||||||
|
assert.strictEqual(state.servers.cwddefault.status, 'healthy', 'Expected default home state path to be used');
|
||||||
|
assert.strictEqual(
|
||||||
|
fs.realpathSync(state.servers.cwddefault.source),
|
||||||
|
fs.realpathSync(configPath),
|
||||||
|
'Expected cwd .claude/settings.json config source'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(tempDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('uses cached healthy and unhealthy states without probing configs', () => {
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
const now = Date.now();
|
||||||
|
const healthyStatePath = path.join(tempDir, 'healthy-state.json');
|
||||||
|
const unhealthyStatePath = path.join(tempDir, 'unhealthy-state.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(healthyStatePath, JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
servers: {
|
||||||
|
cached: {
|
||||||
|
status: 'healthy',
|
||||||
|
checkedAt: now,
|
||||||
|
expiresAt: now + 60000,
|
||||||
|
failureCount: 0,
|
||||||
|
nextRetryAt: now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
fs.writeFileSync(unhealthyStatePath, JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
servers: {
|
||||||
|
blocked: {
|
||||||
|
status: 'unhealthy',
|
||||||
|
checkedAt: now,
|
||||||
|
expiresAt: now,
|
||||||
|
failureCount: 1,
|
||||||
|
nextRetryAt: now + 60000,
|
||||||
|
lastError: 'cached outage'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const healthy = runHook(
|
||||||
|
{ tool_name: 'mcp__cached__list', tool_input: {} },
|
||||||
|
{
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||||
|
ECC_MCP_CONFIG_PATH: path.join(tempDir, 'missing.json'),
|
||||||
|
ECC_MCP_HEALTH_STATE_PATH: healthyStatePath
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const unhealthy = runHook(
|
||||||
|
{ tool_name: 'mcp__blocked__query', tool_input: {} },
|
||||||
|
{
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||||
|
ECC_MCP_CONFIG_PATH: path.join(tempDir, 'missing.json'),
|
||||||
|
ECC_MCP_HEALTH_STATE_PATH: unhealthyStatePath
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(healthy.code, 0, 'Expected cached healthy server to pass without config lookup');
|
||||||
|
assert.strictEqual(healthy.stderr, '', 'Expected cached healthy server to skip logging');
|
||||||
|
assert.strictEqual(unhealthy.code, 2, 'Expected cached unhealthy server to block before retry time');
|
||||||
|
assert.ok(unhealthy.stderr.includes('marked unhealthy until'), `Expected cached unhealthy log, got: ${unhealthy.stderr}`);
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(tempDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('ignores malformed state files and allows missing MCP configs', () => {
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
const statePath = path.join(tempDir, 'malformed-state.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(statePath, '[]');
|
||||||
|
|
||||||
|
const result = runHook(
|
||||||
|
{
|
||||||
|
tool_name: 'Invoke',
|
||||||
|
server: 'ghost',
|
||||||
|
tool: 'lookup',
|
||||||
|
tool_input: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||||
|
ECC_MCP_CONFIG_PATH: path.join(tempDir, 'missing.json'),
|
||||||
|
ECC_MCP_HEALTH_STATE_PATH: statePath
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0, 'Expected missing config to be non-blocking');
|
||||||
|
assert.ok(result.stderr.includes('No MCP config found for ghost'), `Expected missing config log, got: ${result.stderr}`);
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(tempDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (await asyncTest('supports explicit tool_input server targets and mcp_servers config aliases', async () => {
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
const configPath = path.join(tempDir, 'claude.json');
|
||||||
|
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||||
|
const serverScript = path.join(tempDir, 'alias-server.js');
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(serverScript, "setInterval(() => {}, 1000);\n");
|
||||||
|
writeConfig(configPath, {
|
||||||
|
mcp_servers: {
|
||||||
|
alias: createCommandConfig(serverScript)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
tool_name: 'GenericMcpTool',
|
||||||
|
tool_input: {
|
||||||
|
connector: 'alias',
|
||||||
|
mcp_tool: 'lookup'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const result = runHook(input, {
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||||
|
ECC_MCP_CONFIG_PATH: configPath,
|
||||||
|
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||||
|
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0, `Expected explicit MCP target to pass, got ${result.code}: ${result.stderr}`);
|
||||||
|
const state = readState(statePath);
|
||||||
|
assert.strictEqual(state.servers.alias.status, 'healthy', 'Expected alias server to be marked healthy');
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(tempDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (await asyncTest('marks healthy command MCP servers and allows the tool call', async () => {
|
if (await asyncTest('marks healthy command MCP servers and allows the tool call', async () => {
|
||||||
const tempDir = createTempDir();
|
const tempDir = createTempDir();
|
||||||
const configPath = path.join(tempDir, 'claude.json');
|
const configPath = path.join(tempDir, 'claude.json');
|
||||||
@@ -272,6 +469,151 @@ async function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (await asyncTest('blocks unsupported MCP configs and command spawn failures', async () => {
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
const configPath = path.join(tempDir, 'claude.json');
|
||||||
|
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeConfig(configPath, {
|
||||||
|
mcpServers: {
|
||||||
|
unsupported: {},
|
||||||
|
missingcmd: {
|
||||||
|
command: path.join(tempDir, 'missing-mcp-server')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsupported = runHook(
|
||||||
|
{ tool_name: 'mcp__unsupported__search', tool_input: {} },
|
||||||
|
{
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||||
|
ECC_MCP_CONFIG_PATH: configPath,
|
||||||
|
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||||
|
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const missingCommand = runHook(
|
||||||
|
{ tool_name: 'mcp__missingcmd__search', tool_input: {} },
|
||||||
|
{
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||||
|
ECC_MCP_CONFIG_PATH: configPath,
|
||||||
|
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||||
|
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(unsupported.code, 2, 'Expected unsupported config to block');
|
||||||
|
assert.ok(unsupported.stderr.includes('unsupported MCP server config'), `Expected unsupported reason, got: ${unsupported.stderr}`);
|
||||||
|
assert.strictEqual(missingCommand.code, 2, 'Expected missing command to block');
|
||||||
|
assert.ok(/ENOENT|spawn/i.test(missingCommand.stderr), `Expected spawn failure reason, got: ${missingCommand.stderr}`);
|
||||||
|
|
||||||
|
const state = readState(statePath);
|
||||||
|
assert.strictEqual(state.servers.unsupported.status, 'unhealthy', 'Expected unsupported server state');
|
||||||
|
assert.strictEqual(state.servers.missingcmd.status, 'unhealthy', 'Expected missing command server state');
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(tempDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (await asyncTest('includes command stderr and config env in unhealthy probe reasons', async () => {
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
const configPath = path.join(tempDir, 'claude.json');
|
||||||
|
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||||
|
const serverScript = path.join(tempDir, 'stderr-server.js');
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
serverScript,
|
||||||
|
"console.error(`probe failed with ${process.env.ECC_MCP_TEST_MARKER}`); process.exit(1);\n"
|
||||||
|
);
|
||||||
|
writeConfig(configPath, {
|
||||||
|
mcpServers: {
|
||||||
|
stderrprobe: {
|
||||||
|
command: process.execPath,
|
||||||
|
args: [serverScript],
|
||||||
|
env: {
|
||||||
|
ECC_MCP_TEST_MARKER: 'marker-from-config'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runHook(
|
||||||
|
{ tool_name: 'mcp__stderrprobe__search', tool_input: {} },
|
||||||
|
{
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||||
|
ECC_MCP_CONFIG_PATH: configPath,
|
||||||
|
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||||
|
ECC_MCP_HEALTH_TIMEOUT_MS: '100'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 2, 'Expected stderr probe failure to block');
|
||||||
|
assert.ok(result.stderr.includes('marker-from-config'), `Expected command stderr in reason, got: ${result.stderr}`);
|
||||||
|
|
||||||
|
const state = readState(statePath);
|
||||||
|
assert.ok(state.servers.stderrprobe.lastError.includes('marker-from-config'), 'Expected stderr reason in state');
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(tempDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (await asyncTest('records reconnect reprobe failures for previously unhealthy servers', async () => {
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
const configPath = path.join(tempDir, 'claude.json');
|
||||||
|
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||||
|
const serverScript = path.join(tempDir, 'still-down-server.js');
|
||||||
|
const reconnectScript = path.join(tempDir, 'noop-reconnect.js');
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(serverScript, "console.error('503 Service Unavailable'); process.exit(1);\n");
|
||||||
|
fs.writeFileSync(reconnectScript, "process.exit(0);\n");
|
||||||
|
fs.writeFileSync(statePath, JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
servers: {
|
||||||
|
sticky: {
|
||||||
|
status: 'unhealthy',
|
||||||
|
checkedAt: now - 60000,
|
||||||
|
expiresAt: now - 60000,
|
||||||
|
failureCount: 2,
|
||||||
|
lastError: 'previous outage',
|
||||||
|
nextRetryAt: now - 1000,
|
||||||
|
lastRestoredAt: now - 120000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
writeConfig(configPath, {
|
||||||
|
mcpServers: {
|
||||||
|
sticky: createCommandConfig(serverScript)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runHook(
|
||||||
|
{ tool_name: 'mcp__sticky__search', tool_input: {} },
|
||||||
|
{
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||||
|
ECC_MCP_CONFIG_PATH: configPath,
|
||||||
|
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||||
|
ECC_MCP_RECONNECT_COMMAND: `${JSON.stringify(process.execPath)} ${JSON.stringify(reconnectScript)}`,
|
||||||
|
ECC_MCP_HEALTH_TIMEOUT_MS: '100',
|
||||||
|
ECC_MCP_HEALTH_BACKOFF_MS: '10'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 2, 'Expected still-unhealthy server to block');
|
||||||
|
assert.ok(result.stderr.includes('reconnect reprobe failed'), `Expected reprobe failure reason, got: ${result.stderr}`);
|
||||||
|
assert.ok(result.stderr.includes('Reconnect attempt: ok'), `Expected reconnect attempt suffix, got: ${result.stderr}`);
|
||||||
|
|
||||||
|
const state = readState(statePath);
|
||||||
|
assert.strictEqual(state.servers.sticky.failureCount, 3, 'Expected failure count to increment');
|
||||||
|
assert.strictEqual(state.servers.sticky.lastRestoredAt, now - 120000, 'Expected previous restore timestamp to survive');
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(tempDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (await asyncTest('post-failure reconnect command restores server health when a reprobe succeeds', async () => {
|
if (await asyncTest('post-failure reconnect command restores server health when a reprobe succeeds', async () => {
|
||||||
const tempDir = createTempDir();
|
const tempDir = createTempDir();
|
||||||
const configPath = path.join(tempDir, 'claude.json');
|
const configPath = path.join(tempDir, 'claude.json');
|
||||||
@@ -334,6 +676,131 @@ async function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('ignores post-failure events without a reconnect-worthy failure code', () => {
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = runHook(
|
||||||
|
{
|
||||||
|
tool_name: 'mcp__quiet__messages',
|
||||||
|
tool_input: {},
|
||||||
|
error: 'tool returned an application-level validation error'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure',
|
||||||
|
ECC_MCP_HEALTH_STATE_PATH: statePath
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0, 'Expected unmatched post-failure to remain non-blocking');
|
||||||
|
assert.strictEqual(result.stderr, '', 'Expected no logs for unmatched post-failure');
|
||||||
|
assert.strictEqual(fs.existsSync(statePath), false, 'Expected no state write for unmatched post-failure');
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(tempDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('post-failure marks servers unhealthy and skips reconnect when no command is configured', () => {
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = runHook(
|
||||||
|
{
|
||||||
|
tool_name: 'mcp__noplan__messages',
|
||||||
|
tool_input: {},
|
||||||
|
tool_output: {
|
||||||
|
stderr: '403 Forbidden from upstream MCP'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure',
|
||||||
|
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||||
|
ECC_MCP_RECONNECT_COMMAND: null
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0, 'Expected post-failure hook to remain non-blocking');
|
||||||
|
assert.ok(result.stderr.includes('reported 403'), `Expected detected failure code log, got: ${result.stderr}`);
|
||||||
|
assert.ok(result.stderr.includes('reconnect skipped'), `Expected reconnect skipped log, got: ${result.stderr}`);
|
||||||
|
|
||||||
|
const state = readState(statePath);
|
||||||
|
assert.strictEqual(state.servers.noplan.status, 'unhealthy', 'Expected post-failure to mark server unhealthy');
|
||||||
|
assert.strictEqual(state.servers.noplan.lastFailureCode, 403, 'Expected detected status code in state');
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(tempDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('post-failure reports failed reconnect commands', () => {
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||||
|
const reconnectScript = path.join(tempDir, 'failed-reconnect.js');
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(reconnectScript, "console.error('cannot reconnect'); process.exit(7);\n");
|
||||||
|
|
||||||
|
const result = runHook(
|
||||||
|
{
|
||||||
|
tool_name: 'mcp__badreconnect__messages',
|
||||||
|
tool_input: {},
|
||||||
|
tool_response: 'service unavailable 503'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure',
|
||||||
|
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||||
|
ECC_MCP_RECONNECT_COMMAND: `${JSON.stringify(process.execPath)} ${JSON.stringify(reconnectScript)}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0, 'Expected reconnect failure hook to remain non-blocking');
|
||||||
|
assert.ok(result.stderr.includes('reported 503'), `Expected detected failure code log, got: ${result.stderr}`);
|
||||||
|
assert.ok(result.stderr.includes('reconnect failed: cannot reconnect'), `Expected reconnect failure reason, got: ${result.stderr}`);
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(tempDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('post-failure expands per-server reconnect commands before follow-up config checks', () => {
|
||||||
|
const tempDir = createTempDir();
|
||||||
|
const statePath = path.join(tempDir, 'mcp-health.json');
|
||||||
|
const reconnectScript = path.join(tempDir, 'server-reconnect.js');
|
||||||
|
const markerFile = path.join(tempDir, 'server-name.txt');
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
reconnectScript,
|
||||||
|
[
|
||||||
|
"const fs = require('fs');",
|
||||||
|
"fs.writeFileSync(process.argv[2], process.argv[3]);"
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = runHook(
|
||||||
|
{
|
||||||
|
tool_name: 'mcp__foo-bar__messages',
|
||||||
|
tool_input: {},
|
||||||
|
message: 'transport connection reset'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure',
|
||||||
|
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||||
|
ECC_MCP_CONFIG_PATH: path.join(tempDir, 'missing.json'),
|
||||||
|
ECC_MCP_RECONNECT_COMMAND: null,
|
||||||
|
ECC_MCP_RECONNECT_FOO_BAR: `${JSON.stringify(process.execPath)} ${JSON.stringify(reconnectScript)} ${JSON.stringify(markerFile)} {server}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0, 'Expected per-server reconnect hook to remain non-blocking');
|
||||||
|
assert.strictEqual(fs.readFileSync(markerFile, 'utf8'), 'foo-bar', 'Expected {server} token expansion');
|
||||||
|
assert.ok(result.stderr.includes('reported transport'), `Expected transport failure log, got: ${result.stderr}`);
|
||||||
|
assert.ok(result.stderr.includes('no config was available'), `Expected missing config follow-up log, got: ${result.stderr}`);
|
||||||
|
} finally {
|
||||||
|
cleanupTempDir(tempDir);
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (await asyncTest('treats HTTP 400 probe responses as healthy reachable servers', async () => {
|
if (await asyncTest('treats HTTP 400 probe responses as healthy reachable servers', async () => {
|
||||||
const tempDir = createTempDir();
|
const tempDir = createTempDir();
|
||||||
const configPath = path.join(tempDir, 'claude.json');
|
const configPath = path.join(tempDir, 'claude.json');
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ const script = path.join(
|
|||||||
'hooks',
|
'hooks',
|
||||||
'session-activity-tracker.js'
|
'session-activity-tracker.js'
|
||||||
);
|
);
|
||||||
|
const {
|
||||||
|
buildActivityRow,
|
||||||
|
extractFileEvents,
|
||||||
|
extractFilePaths,
|
||||||
|
summarizeOutput,
|
||||||
|
run,
|
||||||
|
} = require(script);
|
||||||
|
|
||||||
function test(name, fn) {
|
function test(name, fn) {
|
||||||
try {
|
try {
|
||||||
@@ -52,6 +59,15 @@ function runScript(input, envOverrides = {}, options = {}) {
|
|||||||
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' };
|
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readMetricRows(homeDir) {
|
||||||
|
const metricsFile = path.join(homeDir, '.claude', 'metrics', 'tool-usage.jsonl');
|
||||||
|
return fs.readFileSync(metricsFile, 'utf8')
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(line => JSON.parse(line));
|
||||||
|
}
|
||||||
|
|
||||||
function runTests() {
|
function runTests() {
|
||||||
console.log('\n=== Testing session-activity-tracker.js ===\n');
|
console.log('\n=== Testing session-activity-tracker.js ===\n');
|
||||||
|
|
||||||
@@ -405,6 +421,246 @@ function runTests() {
|
|||||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||||
}) ? passed++ : failed++);
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('skips non-PostToolUse events and rows without required identifiers', () => {
|
||||||
|
assert.strictEqual(buildActivityRow(
|
||||||
|
{ tool_name: 'Read', tool_input: { file_path: 'README.md' } },
|
||||||
|
{ CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', ECC_SESSION_ID: 'sess' }
|
||||||
|
), null);
|
||||||
|
assert.strictEqual(buildActivityRow(
|
||||||
|
{ tool_name: 'Read', tool_input: { file_path: 'README.md' } },
|
||||||
|
{ CLAUDE_HOOK_EVENT_NAME: 'PostToolUse' }
|
||||||
|
), null);
|
||||||
|
assert.strictEqual(buildActivityRow(
|
||||||
|
{ tool_input: { file_path: 'README.md' } },
|
||||||
|
{ CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', ECC_SESSION_ID: 'sess' }
|
||||||
|
), null);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('sanitizes nested params, long summaries, and output variants', () => {
|
||||||
|
const longValue = `start ${'x'.repeat(260)} ghp_${'A'.repeat(20)}`;
|
||||||
|
const row = buildActivityRow(
|
||||||
|
{
|
||||||
|
tool_name: 'Lookup',
|
||||||
|
tool_input: {
|
||||||
|
query: longValue,
|
||||||
|
secret: `gho_${'B'.repeat(20)}`,
|
||||||
|
count: 3,
|
||||||
|
enabled: false,
|
||||||
|
omitted: null,
|
||||||
|
nested: { a: { b: { c: { d: 'too deep' } } } },
|
||||||
|
list: [1, true, null, 4],
|
||||||
|
},
|
||||||
|
tool_output: `line one\nline two ${'y'.repeat(260)}`,
|
||||||
|
},
|
||||||
|
{ CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', CLAUDE_SESSION_ID: 'claude-fallback' }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(row.session_id, 'claude-fallback');
|
||||||
|
assert.strictEqual(row.file_paths.length, 0);
|
||||||
|
assert.ok(row.input_summary.endsWith('...'), 'Expected long shallow summary to be truncated');
|
||||||
|
assert.ok(!row.input_summary.includes('ghp_'), 'Expected GitHub token redaction in input summary');
|
||||||
|
assert.ok(row.output_summary.endsWith('...'), 'Expected long output summary to be truncated');
|
||||||
|
assert.ok(!row.output_summary.includes('\n'), 'Expected output summary to normalize whitespace');
|
||||||
|
|
||||||
|
const params = JSON.parse(row.input_params_json);
|
||||||
|
assert.strictEqual(params.count, 3);
|
||||||
|
assert.strictEqual(params.enabled, false);
|
||||||
|
assert.strictEqual(params.omitted, null);
|
||||||
|
assert.strictEqual(params.secret, '<REDACTED>');
|
||||||
|
assert.strictEqual(params.nested.a.b.c, '[Truncated]');
|
||||||
|
assert.deepStrictEqual(params.list.slice(0, 3), [1, true, null]);
|
||||||
|
assert.strictEqual(params.list[3], 4);
|
||||||
|
assert.ok(params.query.endsWith('...'), 'Expected long param value to be truncated');
|
||||||
|
|
||||||
|
assert.strictEqual(summarizeOutput(null), '');
|
||||||
|
assert.strictEqual(summarizeOutput(undefined), '');
|
||||||
|
assert.strictEqual(summarizeOutput('hello\nworld'), 'hello world');
|
||||||
|
assert.strictEqual(summarizeOutput({ ok: true }), '{"ok":true}');
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('extracts file paths from nested arrays while filtering duplicates and remote URIs', () => {
|
||||||
|
const paths = extractFilePaths({
|
||||||
|
file_paths: [
|
||||||
|
'src/a.js',
|
||||||
|
'src/a.js',
|
||||||
|
'https://example.com/file.js',
|
||||||
|
'',
|
||||||
|
{ file_path: 'src/b.js' },
|
||||||
|
],
|
||||||
|
nested: {
|
||||||
|
source_path: 'app://connector/item',
|
||||||
|
deep: [
|
||||||
|
{ new_file_path: 'src/c.js' },
|
||||||
|
{ old_file_path: 'plugin://plugin/item' },
|
||||||
|
42,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ignored: 'not-a-path-field',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepStrictEqual(paths, ['src/a.js', 'src/b.js', 'src/c.js']);
|
||||||
|
assert.deepStrictEqual(extractFilePaths(null), []);
|
||||||
|
assert.deepStrictEqual(extractFilePaths('src/not-collected.js'), []);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('extracts file event previews for create delete and one-sided edits', () => {
|
||||||
|
const events = extractFileEvents('Write', {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_path: 'src/new.ts',
|
||||||
|
content: 'first line\nsecond line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file_path: 'src/new.ts',
|
||||||
|
content: 'first line\nsecond line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file_path: 'https://example.com/remote.ts',
|
||||||
|
content: 'ignored',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(events, [
|
||||||
|
{
|
||||||
|
path: 'src/new.ts',
|
||||||
|
action: 'create',
|
||||||
|
diff_preview: '+ first line second line',
|
||||||
|
patch_preview: '+ first line second line',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(extractFileEvents('Remove', {
|
||||||
|
file_path: 'src/old.ts',
|
||||||
|
content: 'legacy line',
|
||||||
|
}), [
|
||||||
|
{
|
||||||
|
path: 'src/old.ts',
|
||||||
|
action: 'delete',
|
||||||
|
patch_preview: '- legacy line',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(extractFileEvents('Edit', {
|
||||||
|
edits: [
|
||||||
|
{ file_path: 'src/before.ts', old_string: 'legacy', new_string: '' },
|
||||||
|
{ file_path: 'src/after.ts', old_string: '', new_string: 'modern' },
|
||||||
|
{ file_path: 'src/no-preview.ts', old_string: '', new_string: '' },
|
||||||
|
],
|
||||||
|
}), [
|
||||||
|
{
|
||||||
|
path: 'src/before.ts',
|
||||||
|
action: 'modify',
|
||||||
|
diff_preview: 'legacy ->',
|
||||||
|
patch_preview: '@@\n- legacy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'src/after.ts',
|
||||||
|
action: 'modify',
|
||||||
|
diff_preview: '-> modern',
|
||||||
|
patch_preview: '@@\n+ modern',
|
||||||
|
},
|
||||||
|
{ path: 'src/no-preview.ts', action: 'modify' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(extractFileEvents('Rename', {
|
||||||
|
old_file_path: 'src/old-name.ts',
|
||||||
|
new_file_path: 'src/new-name.ts',
|
||||||
|
}), [
|
||||||
|
{ path: 'src/old-name.ts', action: 'move' },
|
||||||
|
{ path: 'src/new-name.ts', action: 'move' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(extractFileEvents('Read', null), []);
|
||||||
|
assert.deepStrictEqual(extractFileEvents('Touch', { file_path: 'src/touched.ts' }), [
|
||||||
|
{ path: 'src/touched.ts', action: 'touch' },
|
||||||
|
]);
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('records creation previews unchanged when running outside a git repository', () => {
|
||||||
|
const tmpHome = makeTempDir();
|
||||||
|
const tmpCwd = makeTempDir();
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
tool_name: 'Write',
|
||||||
|
tool_input: {
|
||||||
|
file_path: 'created.txt',
|
||||||
|
content: 'alpha\nbeta',
|
||||||
|
},
|
||||||
|
tool_output: 17,
|
||||||
|
};
|
||||||
|
const result = runScript(input, {
|
||||||
|
...withTempHome(tmpHome),
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||||
|
ECC_SESSION_ID: 'ecc-session-non-git-create',
|
||||||
|
}, {
|
||||||
|
cwd: tmpCwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const [row] = readMetricRows(tmpHome);
|
||||||
|
assert.strictEqual(row.output_summary, '17');
|
||||||
|
assert.deepStrictEqual(row.file_events, [
|
||||||
|
{
|
||||||
|
path: 'created.txt',
|
||||||
|
action: 'create',
|
||||||
|
diff_preview: '+ alpha beta',
|
||||||
|
patch_preview: '+ alpha beta',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tmpCwd, { recursive: true, force: true });
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('preserves absolute paths outside the repo without git enrichment', () => {
|
||||||
|
const tmpHome = makeTempDir();
|
||||||
|
const outsideDir = makeTempDir();
|
||||||
|
const outsideFile = path.join(outsideDir, 'outside.txt');
|
||||||
|
fs.writeFileSync(outsideFile, 'outside', 'utf8');
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
tool_name: 'Read',
|
||||||
|
tool_input: {
|
||||||
|
file_path: outsideFile,
|
||||||
|
},
|
||||||
|
tool_output: 'read outside',
|
||||||
|
};
|
||||||
|
const result = runScript(input, {
|
||||||
|
...withTempHome(tmpHome),
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||||
|
ECC_SESSION_ID: 'ecc-session-absolute-outside',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
const [row] = readMetricRows(tmpHome);
|
||||||
|
assert.deepStrictEqual(row.file_paths, [outsideFile]);
|
||||||
|
assert.deepStrictEqual(row.file_events, [
|
||||||
|
{ path: outsideFile, action: 'read' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||||
|
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('passes empty stdin through without creating metrics', () => {
|
||||||
|
const tmpHome = makeTempDir();
|
||||||
|
const result = runScript('', {
|
||||||
|
...withTempHome(tmpHome),
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||||
|
ECC_SESSION_ID: 'sess-empty',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
assert.strictEqual(result.stdout, '');
|
||||||
|
assert.strictEqual(run(''), '');
|
||||||
|
assert.strictEqual(
|
||||||
|
fs.existsSync(path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl')),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ const os = require('os');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
SESSION_SCHEMA_VERSION,
|
||||||
|
buildAggregates,
|
||||||
getFallbackSessionRecordingPath,
|
getFallbackSessionRecordingPath,
|
||||||
persistCanonicalSnapshot
|
normalizeClaudeHistorySession,
|
||||||
|
normalizeDmuxSnapshot,
|
||||||
|
persistCanonicalSnapshot,
|
||||||
|
validateCanonicalSnapshot
|
||||||
} = require('../../scripts/lib/session-adapters/canonical-session');
|
} = require('../../scripts/lib/session-adapters/canonical-session');
|
||||||
const { createClaudeHistoryAdapter } = require('../../scripts/lib/session-adapters/claude-history');
|
const { createClaudeHistoryAdapter } = require('../../scripts/lib/session-adapters/claude-history');
|
||||||
const { createDmuxTmuxAdapter } = require('../../scripts/lib/session-adapters/dmux-tmux');
|
const { createDmuxTmuxAdapter } = require('../../scripts/lib/session-adapters/dmux-tmux');
|
||||||
@@ -55,6 +60,75 @@ function withHome(homeDir, fn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canonicalSnapshot(overrides = {}) {
|
||||||
|
const snapshot = {
|
||||||
|
schemaVersion: SESSION_SCHEMA_VERSION,
|
||||||
|
adapterId: 'test-adapter',
|
||||||
|
session: {
|
||||||
|
id: 'session-1',
|
||||||
|
kind: 'test',
|
||||||
|
state: 'active',
|
||||||
|
repoRoot: null,
|
||||||
|
sourceTarget: {
|
||||||
|
type: 'session',
|
||||||
|
value: 'session-1'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
workers: [{
|
||||||
|
id: 'worker-1',
|
||||||
|
label: 'Worker 1',
|
||||||
|
state: 'running',
|
||||||
|
health: 'healthy',
|
||||||
|
branch: null,
|
||||||
|
worktree: null,
|
||||||
|
runtime: {
|
||||||
|
kind: 'test-runtime',
|
||||||
|
command: null,
|
||||||
|
pid: null,
|
||||||
|
active: true,
|
||||||
|
dead: false
|
||||||
|
},
|
||||||
|
intent: {
|
||||||
|
objective: 'Test objective',
|
||||||
|
seedPaths: []
|
||||||
|
},
|
||||||
|
outputs: {
|
||||||
|
summary: [],
|
||||||
|
validation: [],
|
||||||
|
remainingRisks: []
|
||||||
|
},
|
||||||
|
artifacts: {}
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
snapshot.aggregates = buildAggregates(snapshot.workers);
|
||||||
|
|
||||||
|
if (overrides.session) {
|
||||||
|
snapshot.session = { ...snapshot.session, ...overrides.session };
|
||||||
|
}
|
||||||
|
if (overrides.sourceTarget) {
|
||||||
|
snapshot.session.sourceTarget = {
|
||||||
|
...snapshot.session.sourceTarget,
|
||||||
|
...overrides.sourceTarget
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(overrides, 'workers')) {
|
||||||
|
snapshot.workers = overrides.workers;
|
||||||
|
snapshot.aggregates = buildAggregates(Array.isArray(overrides.workers) ? overrides.workers : []);
|
||||||
|
}
|
||||||
|
if (overrides.aggregates) {
|
||||||
|
snapshot.aggregates = { ...snapshot.aggregates, ...overrides.aggregates };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
|
if (!['session', 'sourceTarget', 'workers', 'aggregates'].includes(key)) {
|
||||||
|
snapshot[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
test('dmux adapter normalizes orchestration snapshots into canonical form', () => {
|
test('dmux adapter normalizes orchestration snapshots into canonical form', () => {
|
||||||
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-'));
|
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-'));
|
||||||
|
|
||||||
@@ -509,6 +583,324 @@ test('adapter registry lists adapter metadata and target types', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('canonical snapshot validation rejects malformed required fields', () => {
|
||||||
|
const invalidCases = [
|
||||||
|
[null, /must be an object/],
|
||||||
|
[canonicalSnapshot({ schemaVersion: 'ecc.session.v0' }), /Unsupported canonical session schema version/],
|
||||||
|
[canonicalSnapshot({ adapterId: '' }), /adapterId/],
|
||||||
|
[canonicalSnapshot({ session: { id: '' } }), /session.id/],
|
||||||
|
[canonicalSnapshot({ session: { repoRoot: 42 } }), /session.repoRoot/],
|
||||||
|
[canonicalSnapshot({ sourceTarget: { type: '' } }), /session.sourceTarget.type/],
|
||||||
|
[(() => {
|
||||||
|
const snapshot = canonicalSnapshot();
|
||||||
|
snapshot.workers = [null];
|
||||||
|
snapshot.aggregates = { workerCount: 1, states: { unknown: 1 }, healths: { unknown: 1 } };
|
||||||
|
return snapshot;
|
||||||
|
})(), /workers\[0\] to be an object/],
|
||||||
|
[canonicalSnapshot({
|
||||||
|
workers: [{
|
||||||
|
...canonicalSnapshot().workers[0],
|
||||||
|
branch: 7
|
||||||
|
}]
|
||||||
|
}), /workers\[0\].branch/],
|
||||||
|
[canonicalSnapshot({
|
||||||
|
workers: [{
|
||||||
|
...canonicalSnapshot().workers[0],
|
||||||
|
runtime: {
|
||||||
|
...canonicalSnapshot().workers[0].runtime,
|
||||||
|
command: 123
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}), /workers\[0\].runtime.command/],
|
||||||
|
[canonicalSnapshot({
|
||||||
|
workers: [{
|
||||||
|
...canonicalSnapshot().workers[0],
|
||||||
|
runtime: {
|
||||||
|
...canonicalSnapshot().workers[0].runtime,
|
||||||
|
active: 'yes'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}), /workers\[0\].runtime.active/],
|
||||||
|
[canonicalSnapshot({
|
||||||
|
workers: [{
|
||||||
|
...canonicalSnapshot().workers[0],
|
||||||
|
intent: {
|
||||||
|
objective: 'ok',
|
||||||
|
seedPaths: ['README.md', 123]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}), /workers\[0\].intent.seedPaths/],
|
||||||
|
[canonicalSnapshot({
|
||||||
|
workers: [{
|
||||||
|
...canonicalSnapshot().workers[0],
|
||||||
|
outputs: {
|
||||||
|
summary: [],
|
||||||
|
validation: 'nope',
|
||||||
|
remainingRisks: []
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}), /workers\[0\].outputs.validation/],
|
||||||
|
[canonicalSnapshot({ aggregates: { workerCount: 99 } }), /aggregates.workerCount to match/],
|
||||||
|
[canonicalSnapshot({ aggregates: { states: [] } }), /aggregates.states to be an object/],
|
||||||
|
[canonicalSnapshot({ aggregates: { states: { running: -1 } } }), /aggregates.states.running/],
|
||||||
|
[canonicalSnapshot({ aggregates: { healths: null } }), /aggregates.healths to be an object/]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [snapshot, pattern] of invalidCases) {
|
||||||
|
assert.throws(() => validateCanonicalSnapshot(snapshot), pattern);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function dmuxWorker(workerSlug, status = {}, overrides = {}) {
|
||||||
|
return {
|
||||||
|
workerSlug,
|
||||||
|
workerDir: `/tmp/${workerSlug}`,
|
||||||
|
status: {
|
||||||
|
state: 'running',
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
branch: null,
|
||||||
|
worktree: null,
|
||||||
|
...status
|
||||||
|
},
|
||||||
|
task: {
|
||||||
|
objective: `${workerSlug} objective`,
|
||||||
|
seedPaths: ['README.md'],
|
||||||
|
...(overrides.task || {})
|
||||||
|
},
|
||||||
|
handoff: {
|
||||||
|
summary: ['summary'],
|
||||||
|
validation: ['validation'],
|
||||||
|
remainingRisks: ['risk'],
|
||||||
|
...(overrides.handoff || {})
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
status: `/tmp/${workerSlug}/status.md`,
|
||||||
|
task: `/tmp/${workerSlug}/task.md`,
|
||||||
|
handoff: `/tmp/${workerSlug}/handoff.md`,
|
||||||
|
...(overrides.files || {})
|
||||||
|
},
|
||||||
|
pane: Object.prototype.hasOwnProperty.call(overrides, 'pane')
|
||||||
|
? overrides.pane
|
||||||
|
: {
|
||||||
|
currentCommand: 'codex',
|
||||||
|
pid: 123,
|
||||||
|
active: true,
|
||||||
|
dead: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dmuxSnapshot(overrides = {}) {
|
||||||
|
return {
|
||||||
|
sessionName: 'edge-session',
|
||||||
|
repoRoot: '/tmp/repo',
|
||||||
|
sessionActive: false,
|
||||||
|
workerStates: {},
|
||||||
|
workerCount: 0,
|
||||||
|
workers: [],
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('dmux normalization covers missing failed idle and stale worker states', () => {
|
||||||
|
const sourceTarget = { type: 'session', value: 'edge-session' };
|
||||||
|
|
||||||
|
const missing = normalizeDmuxSnapshot(dmuxSnapshot(), sourceTarget);
|
||||||
|
assert.strictEqual(missing.session.state, 'missing');
|
||||||
|
assert.strictEqual(missing.aggregates.workerCount, 0);
|
||||||
|
|
||||||
|
const failed = normalizeDmuxSnapshot(dmuxSnapshot({
|
||||||
|
workerStates: { failed: 1 },
|
||||||
|
workerCount: 1,
|
||||||
|
workers: [
|
||||||
|
dmuxWorker('failure', { state: 'failed' }, { pane: null })
|
||||||
|
]
|
||||||
|
}), sourceTarget);
|
||||||
|
assert.strictEqual(failed.session.state, 'failed');
|
||||||
|
assert.strictEqual(failed.workers[0].health, 'degraded');
|
||||||
|
assert.strictEqual(failed.workers[0].runtime.active, false);
|
||||||
|
assert.strictEqual(failed.workers[0].runtime.dead, false);
|
||||||
|
|
||||||
|
const idle = normalizeDmuxSnapshot(dmuxSnapshot({
|
||||||
|
workerStates: { running: 1, queued: 1 },
|
||||||
|
workerCount: 2,
|
||||||
|
workers: [
|
||||||
|
dmuxWorker('missing-update', { state: 'running', updated: undefined }),
|
||||||
|
dmuxWorker('stale-update', { state: 'active', updated: '2001-01-01T00:00:00Z' }),
|
||||||
|
dmuxWorker('dead-pane', { state: 'running' }, { pane: { dead: true, active: false } }),
|
||||||
|
dmuxWorker('mystery', { state: 'queued' }, {
|
||||||
|
task: { seedPaths: 'not-array' },
|
||||||
|
handoff: { summary: 'not-array', validation: null, remainingRisks: undefined },
|
||||||
|
pane: null
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}), sourceTarget);
|
||||||
|
|
||||||
|
assert.strictEqual(idle.session.state, 'idle');
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
idle.workers.map(worker => worker.health),
|
||||||
|
['stale', 'stale', 'degraded', 'unknown']
|
||||||
|
);
|
||||||
|
assert.deepStrictEqual(idle.workers[3].intent.seedPaths, []);
|
||||||
|
assert.deepStrictEqual(idle.workers[3].outputs.summary, []);
|
||||||
|
|
||||||
|
const completed = normalizeDmuxSnapshot(dmuxSnapshot({
|
||||||
|
workerStates: null,
|
||||||
|
workerCount: 2,
|
||||||
|
workers: [
|
||||||
|
dmuxWorker('done-a', { state: 'done' }),
|
||||||
|
dmuxWorker('done-b', { state: 'success' })
|
||||||
|
]
|
||||||
|
}), sourceTarget);
|
||||||
|
assert.strictEqual(completed.session.state, 'completed');
|
||||||
|
assert.deepStrictEqual(completed.workers.map(worker => worker.health), ['healthy', 'healthy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('claude history normalization falls back to filename ids and empty metadata defaults', () => {
|
||||||
|
const snapshot = normalizeClaudeHistorySession({
|
||||||
|
shortId: 'no-id',
|
||||||
|
filename: '2026-03-13-no-id-session.tmp',
|
||||||
|
sessionPath: '/tmp/2026-03-13-no-id-session.tmp',
|
||||||
|
metadata: {
|
||||||
|
title: '',
|
||||||
|
completed: 'not-array',
|
||||||
|
inProgress: ['Resume from filename fallback'],
|
||||||
|
context: '',
|
||||||
|
notes: ''
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
type: 'claude-history',
|
||||||
|
value: 'latest'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(snapshot.session.id, '2026-03-13-no-id-session');
|
||||||
|
assert.strictEqual(snapshot.workers[0].id, '2026-03-13-no-id-session');
|
||||||
|
assert.strictEqual(snapshot.workers[0].label, '2026-03-13-no-id-session.tmp');
|
||||||
|
assert.strictEqual(snapshot.workers[0].intent.objective, 'Resume from filename fallback');
|
||||||
|
assert.deepStrictEqual(snapshot.workers[0].intent.seedPaths, []);
|
||||||
|
assert.deepStrictEqual(snapshot.workers[0].outputs.summary, []);
|
||||||
|
assert.deepStrictEqual(snapshot.workers[0].outputs.remainingRisks, []);
|
||||||
|
|
||||||
|
const pathOnly = normalizeClaudeHistorySession({
|
||||||
|
sessionPath: '/tmp/path-only-session.tmp',
|
||||||
|
metadata: {
|
||||||
|
title: 'Path Only',
|
||||||
|
inProgress: ['Continue work'],
|
||||||
|
context: ' README.md \n\n scripts/ecc.js ',
|
||||||
|
notes: 'No risks'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
type: 'claude-history',
|
||||||
|
value: '/tmp/path-only-session.tmp'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(pathOnly.session.id, 'path-only-session');
|
||||||
|
assert.strictEqual(pathOnly.workers[0].intent.objective, 'Continue work');
|
||||||
|
assert.deepStrictEqual(pathOnly.workers[0].intent.seedPaths, ['README.md', 'scripts/ecc.js']);
|
||||||
|
assert.deepStrictEqual(pathOnly.workers[0].outputs.remainingRisks, ['No risks']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fallback recordings sanitize paths, use env dirs, and preserve changed history', () => {
|
||||||
|
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-env-'));
|
||||||
|
const previousRecordingDir = process.env.ECC_SESSION_RECORDING_DIR;
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.ECC_SESSION_RECORDING_DIR = recordingDir;
|
||||||
|
const first = canonicalSnapshot({
|
||||||
|
adapterId: 'adapter with spaces',
|
||||||
|
session: { id: 'session id/with:chars' }
|
||||||
|
});
|
||||||
|
const recordingPath = getFallbackSessionRecordingPath(first);
|
||||||
|
assert.ok(recordingPath.includes(`${path.sep}adapter_with_spaces${path.sep}`));
|
||||||
|
assert.ok(recordingPath.endsWith(`${path.sep}session_id_with_chars.json`));
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(recordingPath), { recursive: true });
|
||||||
|
fs.writeFileSync(recordingPath, '{not json', 'utf8');
|
||||||
|
|
||||||
|
const firstPersistence = persistCanonicalSnapshot(first, {
|
||||||
|
loadStateStoreImpl: () => null
|
||||||
|
});
|
||||||
|
const changed = canonicalSnapshot({
|
||||||
|
adapterId: 'adapter with spaces',
|
||||||
|
session: { id: 'session id/with:chars', state: 'idle' }
|
||||||
|
});
|
||||||
|
persistCanonicalSnapshot(changed, { loadStateStoreImpl: () => null });
|
||||||
|
persistCanonicalSnapshot(changed, { loadStateStoreImpl: () => null });
|
||||||
|
|
||||||
|
const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8'));
|
||||||
|
assert.strictEqual(firstPersistence.backend, 'json-file');
|
||||||
|
assert.strictEqual(firstPersistence.path, recordingPath);
|
||||||
|
assert.strictEqual(persisted.schemaVersion, 'ecc.session.recording.v1');
|
||||||
|
assert.strictEqual(persisted.latest.session.state, 'idle');
|
||||||
|
assert.strictEqual(persisted.history.length, 2);
|
||||||
|
assert.strictEqual(persisted.history[0].snapshot.session.state, 'active');
|
||||||
|
assert.strictEqual(persisted.history[1].snapshot.session.state, 'idle');
|
||||||
|
assert.strictEqual(persisted.createdAt, persisted.history[0].recordedAt);
|
||||||
|
} finally {
|
||||||
|
if (typeof previousRecordingDir === 'string') {
|
||||||
|
process.env.ECC_SESSION_RECORDING_DIR = previousRecordingDir;
|
||||||
|
} else {
|
||||||
|
delete process.env.ECC_SESSION_RECORDING_DIR;
|
||||||
|
}
|
||||||
|
fs.rmSync(recordingDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persistence supports skip mode, writer variants, and missing state-store fallback', () => {
|
||||||
|
const snapshot = canonicalSnapshot();
|
||||||
|
const skipped = persistCanonicalSnapshot(snapshot, { persist: false });
|
||||||
|
assert.deepStrictEqual(skipped, {
|
||||||
|
backend: 'skipped',
|
||||||
|
path: null,
|
||||||
|
recordedAt: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const topLevelStore = {
|
||||||
|
calls: [],
|
||||||
|
recordCanonicalSessionSnapshot(snapshotArg, metadata) {
|
||||||
|
this.calls.push({ snapshot: snapshotArg, metadata });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const stateStoreResult = persistCanonicalSnapshot(snapshot, { stateStore: topLevelStore });
|
||||||
|
assert.strictEqual(stateStoreResult.backend, 'state-store');
|
||||||
|
assert.strictEqual(topLevelStore.calls.length, 1);
|
||||||
|
assert.strictEqual(topLevelStore.calls[0].metadata.sessionId, 'session-1');
|
||||||
|
|
||||||
|
const nestedStore = {
|
||||||
|
sessions: {
|
||||||
|
calls: [],
|
||||||
|
recordSessionSnapshot(snapshotArg, metadata) {
|
||||||
|
this.calls.push({ snapshot: snapshotArg, metadata });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
persistCanonicalSnapshot(snapshot, { stateStore: nestedStore });
|
||||||
|
assert.strictEqual(nestedStore.sessions.calls.length, 1);
|
||||||
|
|
||||||
|
const noWriterDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-no-writer-'));
|
||||||
|
const missingModuleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-missing-module-'));
|
||||||
|
try {
|
||||||
|
const noWriter = persistCanonicalSnapshot(snapshot, {
|
||||||
|
recordingDir: noWriterDir,
|
||||||
|
stateStore: { createStateStore() {} }
|
||||||
|
});
|
||||||
|
assert.strictEqual(noWriter.backend, 'json-file');
|
||||||
|
|
||||||
|
const missingModule = new Error("Cannot find module '../state-store'");
|
||||||
|
missingModule.code = 'MODULE_NOT_FOUND';
|
||||||
|
const fallback = persistCanonicalSnapshot(snapshot, {
|
||||||
|
recordingDir: missingModuleDir,
|
||||||
|
loadStateStoreImpl() {
|
||||||
|
throw missingModule;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.strictEqual(fallback.backend, 'json-file');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(noWriterDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(missingModuleDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('persistence only falls back when the state-store module is missing', () => {
|
test('persistence only falls back when the state-store module is missing', () => {
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
schemaVersion: 'ecc.session.v1',
|
schemaVersion: 'ecc.session.v1',
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const repoRoot = path.resolve(__dirname, '..');
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
const repoRootWithSep = `${repoRoot}${path.sep}`;
|
|
||||||
const packageJsonPath = path.join(repoRoot, 'package.json');
|
const packageJsonPath = path.join(repoRoot, 'package.json');
|
||||||
const packageLockPath = path.join(repoRoot, 'package-lock.json');
|
const packageLockPath = path.join(repoRoot, 'package-lock.json');
|
||||||
const rootAgentsPath = path.join(repoRoot, 'AGENTS.md');
|
const rootAgentsPath = path.join(repoRoot, 'AGENTS.md');
|
||||||
@@ -70,16 +69,6 @@ function loadJsonObject(filePath, label) {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertSafeRepoRelativePath(relativePath, label) {
|
|
||||||
const normalized = path.posix.normalize(relativePath.replace(/\\/g, '/'));
|
|
||||||
|
|
||||||
assert.ok(!path.isAbsolute(relativePath), `${label} must not be absolute: ${relativePath}`);
|
|
||||||
assert.ok(
|
|
||||||
!normalized.startsWith('../') && !normalized.includes('/../'),
|
|
||||||
`${label} must not traverse directories: ${relativePath}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectMarkdownFiles(rootPath) {
|
function collectMarkdownFiles(rootPath) {
|
||||||
if (!fs.existsSync(rootPath)) {
|
if (!fs.existsSync(rootPath)) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
Reference in New Issue
Block a user