mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-30 21:45:13 +02:00
Merge branch 'main' into fix/prepush-venv-pytest
This commit is contained in:
@@ -445,6 +445,110 @@ function runTests() {
|
||||
assert.ok(result.stdout.includes('Validated'), 'Should output validation count');
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ==========================================
|
||||
// check-hooks-schema-keys.js
|
||||
// ==========================================
|
||||
console.log('\ncheck-hooks-schema-keys.js:');
|
||||
|
||||
if (test('passes on real project hooks configs', () => {
|
||||
const result = runValidator('check-hooks-schema-keys');
|
||||
assert.strictEqual(result.code, 0, `Should pass, got stderr: ${result.stderr}`);
|
||||
assert.ok(result.stdout.includes('Checked 2 hooks config(s)'), 'Should report both configs checked');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('exits 0 when hooks.json does not exist', () => {
|
||||
const result = runValidatorWithDir('check-hooks-schema-keys', 'HOOKS_FILE', '/nonexistent/hooks.json');
|
||||
assert.strictEqual(result.code, 0, 'Should skip when no hooks.json');
|
||||
assert.ok(result.stdout.includes('skipping'), 'Should say skipping');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails on root $schema key', () => {
|
||||
const testDir = createTestDir();
|
||||
const hooksFile = path.join(testDir, 'hooks.json');
|
||||
fs.writeFileSync(hooksFile, JSON.stringify({
|
||||
$schema: '../schemas/hooks.schema.json',
|
||||
hooks: {}
|
||||
}));
|
||||
|
||||
const result = runValidatorWithDir('check-hooks-schema-keys', 'HOOKS_FILE', hooksFile);
|
||||
assert.strictEqual(result.code, 1, 'Should fail on root $schema');
|
||||
assert.ok(result.stderr.includes('"$schema"'), 'Should name the offending key');
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails on group id and description keys', () => {
|
||||
const testDir = createTestDir();
|
||||
const hooksFile = path.join(testDir, 'hooks.json');
|
||||
fs.writeFileSync(hooksFile, JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [{
|
||||
id: 'test:group',
|
||||
description: 'metadata that belongs in the sidecar',
|
||||
matcher: 'Bash',
|
||||
hooks: [{ type: 'command', command: 'echo hi' }]
|
||||
}]
|
||||
}
|
||||
}));
|
||||
|
||||
const result = runValidatorWithDir('check-hooks-schema-keys', 'HOOKS_FILE', hooksFile);
|
||||
assert.strictEqual(result.code, 1, 'Should fail on group id/description');
|
||||
assert.ok(result.stderr.includes('"id"'), 'Should name id');
|
||||
assert.ok(result.stderr.includes('"description"'), 'Should name description');
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails on unknown handler key', () => {
|
||||
const testDir = createTestDir();
|
||||
const hooksFile = path.join(testDir, 'hooks.json');
|
||||
fs.writeFileSync(hooksFile, JSON.stringify({
|
||||
hooks: {
|
||||
Stop: [{ hooks: [{ type: 'command', command: 'echo hi', label: 'not a loader key' }] }]
|
||||
}
|
||||
}));
|
||||
|
||||
const result = runValidatorWithDir('check-hooks-schema-keys', 'HOOKS_FILE', hooksFile);
|
||||
assert.strictEqual(result.code, 1, 'Should fail on unknown handler key');
|
||||
assert.ok(result.stderr.includes('"label"'), 'Should name the offending handler key');
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('fails on codex-hooks.json root $schema key', () => {
|
||||
const testDir = createTestDir();
|
||||
const hooksFile = path.join(testDir, 'codex-hooks.json');
|
||||
fs.writeFileSync(hooksFile, JSON.stringify({
|
||||
$schema: '../schemas/hooks.schema.json',
|
||||
description: 'codex projection',
|
||||
hooks: {
|
||||
SessionStart: [{ id: 'session:start', matcher: '.*', hooks: [{ type: 'command', command: 'echo hi' }] }]
|
||||
}
|
||||
}));
|
||||
|
||||
const result = runValidatorWithDir('check-hooks-schema-keys', 'CODEX_HOOKS_FILE', hooksFile);
|
||||
assert.strictEqual(result.code, 1, 'Should fail on codex root $schema');
|
||||
assert.ok(result.stderr.includes('"$schema"'), 'Should name the offending key');
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('accepts codex documented keys including group id and root description', () => {
|
||||
const testDir = createTestDir();
|
||||
const hooksFile = path.join(testDir, 'codex-hooks.json');
|
||||
fs.writeFileSync(hooksFile, JSON.stringify({
|
||||
description: 'codex projection',
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
id: 'session:start',
|
||||
description: 'pinned by plugin-manifest test',
|
||||
matcher: '.*',
|
||||
hooks: [{ type: 'command', command: 'echo hi', timeout: 5 }]
|
||||
}]
|
||||
}
|
||||
}));
|
||||
|
||||
const result = runValidatorWithDir('check-hooks-schema-keys', 'CODEX_HOOKS_FILE', hooksFile);
|
||||
assert.strictEqual(result.code, 0, `Should pass, got stderr: ${result.stderr}`);
|
||||
cleanupTestDir(testDir);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// ==========================================
|
||||
// catalog.js
|
||||
// ==========================================
|
||||
|
||||
@@ -63,6 +63,49 @@ function runTests() {
|
||||
assert.strictEqual(result.stdout, '', `Pass-through must emit empty stdout, got: ${result.stdout}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('pre dispatcher fails closed when its configured byte cap truncates input', () => {
|
||||
const input = {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: `echo ${'x'.repeat(256)}` }
|
||||
};
|
||||
const result = runScript(preDispatcher, input, {
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
ECC_HOOK_INPUT_MAX_BYTES: '64'
|
||||
});
|
||||
assert.strictEqual(result.status, 2, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.match(result.stderr, /safety checks require the complete request/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('pre dispatcher applies its byte cap at UTF-8 boundaries', () => {
|
||||
const input = {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: String.fromCodePoint(0xe9).repeat(64) }
|
||||
};
|
||||
const result = runScript(preDispatcher, input, {
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
ECC_HOOK_INPUT_MAX_BYTES: '65'
|
||||
});
|
||||
assert.strictEqual(result.status, 2, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.match(result.stderr, /stdin exceeded 65 bytes/);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('disabled pre dispatcher does not block truncated input', () => {
|
||||
const input = {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: `echo ${'x'.repeat(256)}` }
|
||||
};
|
||||
for (const env of [
|
||||
{ ECC_HOOK_INPUT_MAX_BYTES: '64', ECC_DISABLED_HOOKS: 'pre:bash:dispatcher' },
|
||||
{ ECC_HOOK_INPUT_MAX_BYTES: '64', ECC_HOOKS_ENABLED: 'false' }
|
||||
]) {
|
||||
const result = runScript(preDispatcher, input, env);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('pre dispatcher still honors per-hook disable flags', () => {
|
||||
const input = { tool_input: { command: 'git push origin main' } };
|
||||
|
||||
|
||||
@@ -112,10 +112,9 @@ function runTests() {
|
||||
}
|
||||
};
|
||||
|
||||
const rawInput = JSON.stringify(input);
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, 'Expected safe file edit to pass');
|
||||
assert.strictEqual(result.stdout, rawInput, 'Expected exact raw JSON passthrough');
|
||||
assert.strictEqual(result.stdout, '', 'Allowed edits should not echo raw hook input');
|
||||
assert.strictEqual(result.stderr, '', 'Expected no stderr for safe edits');
|
||||
})
|
||||
)
|
||||
@@ -155,10 +154,9 @@ function runTests() {
|
||||
}
|
||||
};
|
||||
|
||||
const rawInput = JSON.stringify(input);
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0 for first-time creation, got ${result.code}; stderr: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, rawInput, 'Expected raw passthrough when creation is allowed');
|
||||
assert.strictEqual(result.stdout, '', 'Allowed creation should not echo raw hook input');
|
||||
assert.strictEqual(result.stderr, '', `Expected no stderr for first-time creation, got: ${result.stderr}`);
|
||||
} finally {
|
||||
try {
|
||||
@@ -189,10 +187,9 @@ function runTests() {
|
||||
}
|
||||
};
|
||||
|
||||
const rawInput = JSON.stringify(input);
|
||||
const result = runHook(input);
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0 for ENOENT path, got ${result.code}; stderr: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, rawInput, 'Expected raw passthrough when path does not exist');
|
||||
assert.strictEqual(result.stdout, '', 'Allowed missing paths should not echo raw hook input');
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
@@ -241,13 +241,7 @@ function runTests() {
|
||||
};
|
||||
const result = runHook(input, { GATEGUARD_STATE_DIR: invalidStateDir });
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce valid JSON output');
|
||||
if (output.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny', 'unpersistable state must not deny a retry that can never be recorded');
|
||||
} else {
|
||||
assert.strictEqual(output.tool_name, 'Write', 'pass-through should preserve input');
|
||||
}
|
||||
assert.strictEqual(result.stdout, '', 'fail-open result without an explicit decision must stay silent');
|
||||
assert.ok(result.stderr.includes('GateGuard state could not be persisted'), 'should warn that state persistence failed');
|
||||
})
|
||||
)
|
||||
@@ -487,14 +481,7 @@ function runTests() {
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, 'exit code should be 0');
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, 'should produce valid JSON output');
|
||||
if (output.hookSpecificOutput) {
|
||||
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny', 'should not deny when hook is disabled');
|
||||
} else {
|
||||
// When disabled, hook passes through raw input
|
||||
assert.strictEqual(output.tool_name, 'Edit', 'pass-through should preserve input');
|
||||
}
|
||||
assert.strictEqual(result.stdout, '', 'disabled wrapper hook must stay silent');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
|
||||
@@ -247,7 +247,7 @@ function runTests() {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, raw);
|
||||
assert.strictEqual(result.stdout, '', 'disabled wrapper hooks must not echo stdin');
|
||||
assert.ok(!fs.existsSync(markerPath), 'disabled wrapper hook must not execute');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Regression tests for bounded hook stdin reads.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const { PassThrough } = require('stream');
|
||||
const { readStdinRaw } = require('../../scripts/hooks/hook-input');
|
||||
const { run: runConfigProtection } = require('../../scripts/hooks/config-protection');
|
||||
|
||||
const TEST_STDIN_LIMIT = 1024;
|
||||
const STREAM_SETTLEMENT_TIMEOUT_MS = 500;
|
||||
|
||||
async function test(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readFromErroredStream(partialInput) {
|
||||
const stream = new PassThrough();
|
||||
const resultPromise = readStdinRaw(stream, { maxStdin: TEST_STDIN_LIMIT });
|
||||
stream.write(partialInput);
|
||||
stream.destroy(new Error('simulated stdin read failure'));
|
||||
return resultPromise;
|
||||
}
|
||||
|
||||
async function readFromClosedStream(partialInput) {
|
||||
const stream = new PassThrough();
|
||||
const resultPromise = readStdinRaw(stream, { maxStdin: TEST_STDIN_LIMIT });
|
||||
stream.write(partialInput);
|
||||
stream.destroy();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('readStdinRaw did not settle after stream close')),
|
||||
STREAM_SETTLEMENT_TIMEOUT_MS
|
||||
);
|
||||
resultPromise.then(
|
||||
result => {
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
},
|
||||
error => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('\nHook input reader tests:');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (
|
||||
await test('clean end preserves complete input', async () => {
|
||||
const stream = new PassThrough();
|
||||
const resultPromise = readStdinRaw(stream, { maxStdin: TEST_STDIN_LIMIT });
|
||||
stream.end('{"complete":true}');
|
||||
|
||||
assert.deepStrictEqual(await resultPromise, {
|
||||
raw: '{"complete":true}',
|
||||
truncated: false
|
||||
});
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('stream error marks partial input as truncated', async () => {
|
||||
const partialInput = '{"tool_name":"Write","tool_input":{';
|
||||
const result = await readFromErroredStream(partialInput);
|
||||
|
||||
assert.strictEqual(result.raw, partialInput);
|
||||
assert.strictEqual(result.truncated, true);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('close without end marks partial input as truncated', async () => {
|
||||
const partialInput = '{"tool_name":"Write","tool_input":{';
|
||||
const result = await readFromClosedStream(partialInput);
|
||||
|
||||
assert.strictEqual(result.raw, partialInput);
|
||||
assert.strictEqual(result.truncated, true);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
await test('errored partial PreToolUse input remains fail closed', async () => {
|
||||
const partialInput = '{"tool_name":"Write","tool_input":{"file_path":".eslintrc.js"';
|
||||
const inputResult = await readFromErroredStream(partialInput);
|
||||
const hookResult = runConfigProtection(inputResult.raw, {
|
||||
truncated: inputResult.truncated,
|
||||
maxStdin: TEST_STDIN_LIMIT
|
||||
});
|
||||
|
||||
assert.strictEqual(inputResult.truncated, true);
|
||||
assert.strictEqual(hookResult.exitCode, 2);
|
||||
assert.match(hookResult.stderr, /Refusing to bypass config-protection/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}\n`);
|
||||
process.exitCode = failed > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
runTests().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -2838,8 +2838,9 @@ async function runTests() {
|
||||
(Array.isArray(hook.command) && hook.command[0] === 'node' && hook.command[1] === '-e') || (typeof hook.command === 'string' && hook.command.startsWith('node -e "')),
|
||||
'Lifecycle hook should use inline node resolver'
|
||||
);
|
||||
assert.ok(commandText.includes('run-with-flags.js'), 'Lifecycle hook should resolve the runner script');
|
||||
assert.ok(commandText.includes('lifecycle-hook-bootstrap.js'), 'Lifecycle hook should resolve the shared lifecycle bootstrap');
|
||||
assert.ok(commandText.includes('CLAUDE_PLUGIN_ROOT'), 'Lifecycle hook should consult CLAUDE_PLUGIN_ROOT');
|
||||
assert.ok(commandText.includes("process.platform==='win32'"), 'Lifecycle hook should normalize Git Bash drive roots before loading the bootstrap');
|
||||
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), 'Lifecycle hook should not depend on raw shell placeholder expansion');
|
||||
assert.ok(commandText.includes('resolve-ecc-root'), 'Lifecycle hook should delegate to the committed resolver module');
|
||||
assert.ok(!commandText.includes('find '), 'Lifecycle hook should not scan arbitrary plugin paths with find');
|
||||
@@ -2863,8 +2864,9 @@ async function runTests() {
|
||||
const usesInlineResolver = commandStart.startsWith('node -e') && commandText.includes('run-with-flags.js');
|
||||
const usesPluginBootstrap = commandStart.startsWith('node -e') && commandText.includes('plugin-hook-bootstrap.js');
|
||||
const usesDirectPostDispatcher = commandStart.startsWith('node -e') && commandText.includes('posttooluse-dispatcher.js') && commandText.includes('resolve-ecc-root');
|
||||
const usesLifecycleBootstrap = commandStart.startsWith('node -e') && commandText.includes('lifecycle-hook-bootstrap.js') && commandText.includes('resolve-ecc-root');
|
||||
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), `Script paths should not depend on raw shell placeholder expansion: ${commandText.substring(0, 80)}...`);
|
||||
assert.ok(usesInlineResolver || usesPluginBootstrap || usesDirectPostDispatcher, `Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`);
|
||||
assert.ok(usesInlineResolver || usesPluginBootstrap || usesDirectPostDispatcher || usesLifecycleBootstrap, `Script paths should use a safe inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'plugin-hook-bootstrap.js');
|
||||
const LIFECYCLE_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'lifecycle-hook-bootstrap.js');
|
||||
const { normalizePluginRootForPlatform, withComparisonInput } = require(SCRIPT);
|
||||
const { resolveTimeout } = require(LIFECYCLE_SCRIPT);
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-hook-bootstrap-'));
|
||||
@@ -126,6 +128,16 @@ function runTests() {
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('lifecycle bootstrap shares Windows root normalization and bounds timeouts', () => {
|
||||
const rootResolver = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'resolve-ecc-root.js'));
|
||||
assert.strictEqual(
|
||||
rootResolver.normalizePluginRootForPlatform('/c/Users/x/.claude/plugins/ecc', 'win32'),
|
||||
'C:/Users/x/.claude/plugins/ecc'
|
||||
);
|
||||
assert.strictEqual(resolveTimeout('600000'), 300000);
|
||||
assert.strictEqual(resolveTimeout('invalid'), 30000);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('node mode runs target script with plugin root environment', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
|
||||
@@ -71,6 +71,30 @@ function runConfiguredCommand(entry, raw, env = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function runInspectingDispatcher(input, env = {}) {
|
||||
const script = [
|
||||
`const dispatcher = require(${JSON.stringify(dispatcherPath)});`,
|
||||
"const hooks = [{ id: 'post:test:inspect', matcher: '*', profiles: 'standard,strict', run: (raw, context) => ({ stdout: JSON.stringify({ raw: raw.length <= 16 ? raw : null, bytes: Buffer.byteLength(raw, 'utf8'), truncated: context.truncated, maxStdin: context.maxStdin }) }) }];",
|
||||
"process.argv[2] = 'sync';",
|
||||
'dispatcher.cli({ hookListOverride: hooks });'
|
||||
].join('');
|
||||
return spawnSync(process.execPath, ['-e', script], {
|
||||
cwd: repoRoot,
|
||||
input,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: repoRoot,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
ECC_DISABLED_HOOKS: '',
|
||||
...env,
|
||||
ECC_DRY_RUN: '0'
|
||||
},
|
||||
timeout: 10000,
|
||||
maxBuffer: 4 * 1024 * 1024
|
||||
});
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== PostToolUse dispatcher tests ===\n');
|
||||
|
||||
@@ -97,6 +121,10 @@ function runTests() {
|
||||
entries.every(entry => !entry.hooks[0].command.includes('plugin-hook-bootstrap.js')),
|
||||
'PostToolUse dispatchers should not spawn a second Node bootstrap process'
|
||||
);
|
||||
assert.ok(
|
||||
entries.every(entry => !entry.hooks[0].command.includes('ECC_POSTTOOLUSE_PASSTHROUGH')),
|
||||
'PostToolUse commands must not opt back into raw stdin passthrough'
|
||||
);
|
||||
assert.ok(entries[1].hooks[0].timeout >= 30);
|
||||
})
|
||||
)
|
||||
@@ -162,7 +190,7 @@ function runTests() {
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('actual hooks.json commands preserve Edit dry-run output and IDs', () => {
|
||||
test('actual hooks.json commands keep Edit dry-run silent and preserve IDs', () => {
|
||||
const entries = readHooksConfig(hooksPath).hooks.PostToolUse;
|
||||
const raw = JSON.stringify({
|
||||
hook_event_name: 'PostToolUse',
|
||||
@@ -174,7 +202,7 @@ function runTests() {
|
||||
|
||||
for (const result of results) {
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, raw, 'configured command should preserve pass-through output');
|
||||
assert.strictEqual(result.stdout, '', 'configured command should stay silent when no child hook emits output');
|
||||
}
|
||||
const ids = results.flatMap(result => previewedIds(result.stderr));
|
||||
assert.deepStrictEqual(ids, [
|
||||
@@ -219,6 +247,94 @@ function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('legacy passthrough env cannot restore silent sync or async output', () => {
|
||||
for (const mode of ['sync', 'async']) {
|
||||
const result = runDispatcher(mode, 'Read', {
|
||||
ECC_DRY_RUN: '1',
|
||||
ECC_POSTTOOLUSE_PASSTHROUGH: '1'
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '', `${mode} dispatcher must ignore legacy passthrough opt-in`);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('configured stdin cap controls PostToolUse input and hook context', () => {
|
||||
const result = runInspectingDispatcher('x'.repeat(256), { ECC_HOOK_INPUT_MAX_BYTES: '128' });
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(result.stdout), {
|
||||
raw: null,
|
||||
bytes: 128,
|
||||
truncated: true,
|
||||
maxStdin: 128
|
||||
});
|
||||
assert.match(result.stderr, /stdin exceeded 128 bytes/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('PostToolUse stdin cap honors UTF-8 byte boundaries', () => {
|
||||
const character = String.fromCodePoint(0xe9);
|
||||
const exact = runInspectingDispatcher(character.repeat(2), { ECC_HOOK_INPUT_MAX_BYTES: '4' });
|
||||
assert.strictEqual(exact.status, 0, exact.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(exact.stdout), {
|
||||
raw: character.repeat(2),
|
||||
bytes: 4,
|
||||
truncated: false,
|
||||
maxStdin: 4
|
||||
});
|
||||
|
||||
const truncated = runInspectingDispatcher(character.repeat(2), { ECC_HOOK_INPUT_MAX_BYTES: '3' });
|
||||
assert.strictEqual(truncated.status, 0, truncated.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(truncated.stdout), {
|
||||
raw: character,
|
||||
bytes: 2,
|
||||
truncated: true,
|
||||
maxStdin: 3
|
||||
});
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('invalid PostToolUse stdin caps fall back with a diagnostic', () => {
|
||||
for (const value of ['0', '-1', '1.5', 'not-a-number']) {
|
||||
const result = runInspectingDispatcher('payload', { ECC_HOOK_INPUT_MAX_BYTES: value });
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(JSON.parse(result.stdout).maxStdin, 1024 * 1024);
|
||||
assert.match(result.stderr, /must be a positive safe integer/);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('PostToolUse stdin cap cannot exceed the 1 MiB safety maximum', () => {
|
||||
const result = runInspectingDispatcher('x'.repeat(1024 * 1024 + 1), {
|
||||
ECC_HOOK_INPUT_MAX_BYTES: String(2 * 1024 * 1024)
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(result.stdout), {
|
||||
raw: null,
|
||||
bytes: 1024 * 1024,
|
||||
truncated: true,
|
||||
maxStdin: 1024 * 1024
|
||||
});
|
||||
assert.match(result.stderr, /exceeds the 1 MiB safety maximum/);
|
||||
assert.match(result.stderr, /stdin exceeded 1048576 bytes/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('profiles and disabled IDs remain scoped to each original hook', () => {
|
||||
const minimalSync = runDispatcher('sync', 'Edit', {
|
||||
@@ -286,7 +402,7 @@ function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.deepStrictEqual(previewedIds(result.stderr), [], `${entry.id} should disable all child hooks`);
|
||||
assert.strictEqual(result.stdout, raw);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -371,6 +487,23 @@ function runTests() {
|
||||
assert.ok(result.stderr.indexOf('post:test:broken') < result.stderr.indexOf('last warning'));
|
||||
assert.strictEqual(result.exitCode, 7, 'explicit child exit codes should be preserved');
|
||||
assert.strictEqual(resolveMainStdout(raw, { stdout: '', exitCode: 7 }, { passthrough: true, truncated: false }), '', 'nonzero results should not restore raw input');
|
||||
assert.strictEqual(resolveMainStdout(raw, { stdout: '', exitCode: 0 }, { passthrough: true, truncated: false }), '', 'silent successful results must not restore raw input');
|
||||
|
||||
const explicitFailure = runHooks(
|
||||
raw,
|
||||
[
|
||||
{
|
||||
id: 'post:test:explicit-failure',
|
||||
matcher: '*',
|
||||
profiles: 'standard,strict',
|
||||
run: () => ({ stdout: explicitOutput, stderr: 'failure detail', exitCode: 9 })
|
||||
}
|
||||
],
|
||||
{ toolName: 'Read', env: { ECC_HOOK_PROFILE: 'standard' } }
|
||||
);
|
||||
assert.strictEqual(explicitFailure.stdout, explicitOutput);
|
||||
assert.strictEqual(explicitFailure.exitCode, 9);
|
||||
assert.match(explicitFailure.stderr, /failure detail/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
@@ -380,16 +513,20 @@ function runTests() {
|
||||
test('failing hook exit code propagates to the real dispatcher process status', () => {
|
||||
const script = [
|
||||
`const dispatcher = require(${JSON.stringify(dispatcherPath)});`,
|
||||
'dispatcher.SYNC_HOOKS.length = 0;',
|
||||
"dispatcher.SYNC_HOOKS.push({ id: 'post:test:fail', matcher: '*', profiles: 'standard,strict', run: () => ({ exitCode: 7 }) });",
|
||||
"const hooks = [{ id: 'post:test:fail', matcher: '*', profiles: 'standard,strict', run: () => ({ exitCode: 7 }) }];",
|
||||
"process.argv[2] = 'sync';",
|
||||
'dispatcher.cli();'
|
||||
'dispatcher.cli({ hookListOverride: hooks });'
|
||||
].join('');
|
||||
const result = spawnSync(process.execPath, ['-e', script], {
|
||||
cwd: repoRoot,
|
||||
input: JSON.stringify({ hook_event_name: 'PostToolUse', tool_name: 'Read', tool_input: {}, tool_response: {} }),
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, CLAUDE_PLUGIN_ROOT: repoRoot, ECC_POSTTOOLUSE_PASSTHROUGH: '1' },
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: repoRoot,
|
||||
ECC_POSTTOOLUSE_PASSTHROUGH: '1',
|
||||
ECC_DRY_RUN: '0'
|
||||
},
|
||||
timeout: 10000
|
||||
});
|
||||
assert.strictEqual(result.status, 7, 'OS-level exit status should reflect the failing hook');
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* Regression tests for #2600: silent hook paths must not echo stdin.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const runner = path.join(repoRoot, 'scripts', 'hooks', 'run-with-flags.js');
|
||||
const sessionStartBootstrap = path.join(repoRoot, 'scripts', 'hooks', 'session-start-bootstrap.js');
|
||||
const { readHooksConfig } = require(path.join(repoRoot, 'scripts', 'lib', 'hooks-config.js'));
|
||||
const hooksConfig = readHooksConfig(path.join(repoRoot, 'hooks', 'hooks.json'));
|
||||
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hook-no-output-'));
|
||||
const hooksDir = path.join(pluginRoot, 'hooks');
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
|
||||
const payload = JSON.stringify({
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: 'README.md' },
|
||||
tool_response: { content: 'payload that must not be duplicated' }
|
||||
});
|
||||
|
||||
function writeFixture(name, source) {
|
||||
fs.writeFileSync(path.join(hooksDir, name), source);
|
||||
}
|
||||
|
||||
writeFixture('undefined.js', "module.exports.run = () => undefined;\n");
|
||||
writeFixture('object.js', "module.exports.run = () => ({ exitCode: 0 });\n");
|
||||
writeFixture('throws.js', "module.exports.run = () => { throw new Error('fixture failure'); };\n");
|
||||
writeFixture('explicit.js', "module.exports.run = () => 'explicit output';\n");
|
||||
writeFixture('buffer.js', "module.exports.run = () => Buffer.from('buffer output');\n");
|
||||
writeFixture('stdout.js', "module.exports.run = () => ({ stdout: 'object stdout' });\n");
|
||||
writeFixture('context.js', "module.exports.run = () => ({ additionalContext: 'context output' });\n");
|
||||
writeFixture('stderr.js', "module.exports.run = () => ({ stderr: 'diagnostic only', exitCode: 0 });\n");
|
||||
writeFixture('nonzero.js', "module.exports.run = () => ({ stderr: 'blocked', exitCode: 7 });\n");
|
||||
writeFixture('nonzero-output.js', "module.exports.run = () => ({ stdout: 'blocking output', stderr: 'blocked', exitCode: 7 });\n");
|
||||
writeFixture('direct-echo.js', 'module.exports.run = raw => raw;\n');
|
||||
writeFixture(
|
||||
'inspect-input.js',
|
||||
"module.exports.run = (raw, context) => JSON.stringify({ raw, bytes: Buffer.byteLength(raw, 'utf8'), truncated: context.truncated, maxStdin: context.maxStdin });\n"
|
||||
);
|
||||
writeFixture('legacy-empty.js', "process.stdin.resume(); process.stdin.on('end', () => process.exit(0));\n");
|
||||
writeFixture('legacy-echo.js', 'process.stdin.pipe(process.stdout);\n');
|
||||
writeFixture(
|
||||
'legacy-inspect.js',
|
||||
"let raw=''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { raw += chunk; }); process.stdin.on('end', () => process.stdout.write(JSON.stringify({ bytes: Buffer.byteLength(raw, 'utf8'), truncated: process.env.ECC_HOOK_INPUT_TRUNCATED, maxStdin: process.env.ECC_HOOK_INPUT_MAX_BYTES })));\n"
|
||||
);
|
||||
|
||||
function run(args, env = {}, input = payload) {
|
||||
return spawnSync(process.execPath, [runner, ...args], {
|
||||
input,
|
||||
encoding: 'utf8',
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: pluginRoot,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
...env
|
||||
},
|
||||
timeout: 30000,
|
||||
maxBuffer: 4 * 1024 * 1024
|
||||
});
|
||||
}
|
||||
|
||||
function runConfiguredHook(entry, env = {}, input = payload) {
|
||||
return spawnSync(entry.hooks[0].command, {
|
||||
input,
|
||||
encoding: 'utf8',
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: repoRoot,
|
||||
ECC_PLUGIN_ROOT: repoRoot,
|
||||
ECC_AGENT_DATA_HOME: path.join(pluginRoot, 'agent-data'),
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
...env
|
||||
},
|
||||
shell: true,
|
||||
timeout: 30000,
|
||||
maxBuffer: 4 * 1024 * 1024
|
||||
});
|
||||
}
|
||||
|
||||
function runSessionStartBootstrapWithMissingRoot(input = payload) {
|
||||
const missingRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-start-missing-root-'));
|
||||
fs.rmSync(missingRoot, { recursive: true, force: true });
|
||||
return spawnSync(process.execPath, [sessionStartBootstrap], {
|
||||
input,
|
||||
encoding: 'utf8',
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: missingRoot,
|
||||
ECC_PLUGIN_ROOT: missingRoot
|
||||
},
|
||||
timeout: 30000,
|
||||
maxBuffer: 4 * 1024 * 1024
|
||||
});
|
||||
}
|
||||
|
||||
function runSessionStartBootstrapWithLargeOutput(channel, exitCode) {
|
||||
const outputBytes = 512 * 1024;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-start-output-'));
|
||||
const fixtureRunner = path.join(root, 'scripts', 'hooks', 'run-with-flags.js');
|
||||
fs.mkdirSync(path.dirname(fixtureRunner), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
fixtureRunner,
|
||||
[
|
||||
"const size = Number(process.env.ECC_TEST_OUTPUT_BYTES);",
|
||||
"const output = 'x'.repeat(size);",
|
||||
"if (process.env.ECC_TEST_OUTPUT_CHANNEL !== 'stderr') process.stdout.write(output);",
|
||||
"if (process.env.ECC_TEST_OUTPUT_CHANNEL !== 'stdout') process.stderr.write(output.replaceAll('x', 'y'));",
|
||||
"process.exitCode = Number(process.env.ECC_TEST_EXIT_CODE);"
|
||||
].join('\n') + '\n'
|
||||
);
|
||||
|
||||
try {
|
||||
return spawnSync(process.execPath, [sessionStartBootstrap], {
|
||||
input: payload,
|
||||
encoding: 'utf8',
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: root,
|
||||
ECC_PLUGIN_ROOT: root,
|
||||
ECC_TEST_OUTPUT_BYTES: String(outputBytes),
|
||||
ECC_TEST_OUTPUT_CHANNEL: channel,
|
||||
ECC_TEST_EXIT_CODE: String(exitCode)
|
||||
},
|
||||
timeout: 30000,
|
||||
maxBuffer: 4 * 1024 * 1024
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runConfiguredHookWithMissingRoot(entry, input = payload) {
|
||||
const missingRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hook-missing-root-'));
|
||||
fs.rmSync(missingRoot, { recursive: true, force: true });
|
||||
return runConfiguredHook(
|
||||
entry,
|
||||
{ CLAUDE_PLUGIN_ROOT: missingRoot, ECC_PLUGIN_ROOT: missingRoot },
|
||||
input
|
||||
);
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` [PASS] ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` [FAIL] ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSilent(result) {
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
}
|
||||
|
||||
console.log('\nrun-with-flags no-output contract tests (#2600):');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const silentCases = [
|
||||
['missing arguments', [], {}],
|
||||
['disabled hook', ['post:test', 'hooks/undefined.js', 'standard'], { ECC_DISABLED_HOOKS: 'post:test' }],
|
||||
['dry run', ['post:test', 'hooks/undefined.js', 'standard'], { ECC_DRY_RUN: '1' }],
|
||||
['missing script', ['post:test', 'hooks/missing.js', 'standard'], {}],
|
||||
['path traversal rejection', ['post:test', '../outside.js', 'standard'], {}],
|
||||
['undefined run result', ['post:test', 'hooks/undefined.js', 'standard'], {}],
|
||||
['object result without output', ['post:test', 'hooks/object.js', 'standard'], {}],
|
||||
['run exception', ['post:test', 'hooks/throws.js', 'standard'], {}],
|
||||
['legacy process with empty stdout', ['post:test', 'hooks/legacy-empty.js', 'standard'], {}]
|
||||
];
|
||||
|
||||
for (const [name, args, env] of silentCases) {
|
||||
if (test(`${name} emits empty stdout`, () => assertSilent(run(args, env)))) passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
const explicitCases = [
|
||||
['string output', 'hooks/explicit.js', 'explicit output'],
|
||||
['Buffer output', 'hooks/buffer.js', 'buffer output'],
|
||||
['stdout property', 'hooks/stdout.js', 'object stdout']
|
||||
];
|
||||
|
||||
for (const [name, fixture, expected] of explicitCases) {
|
||||
if (
|
||||
test(`preserves explicit ${name}`, () => {
|
||||
const result = run(['post:test', fixture, 'standard']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, expected);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
if (
|
||||
test('preserves additionalContext output', () => {
|
||||
const result = run(['post:test', 'hooks/context.js', 'standard']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(result.stdout), {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext: 'context output'
|
||||
}
|
||||
});
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('preserves stderr while keeping diagnostic-only success silent', () => {
|
||||
const result = run(['post:test', 'hooks/stderr.js', 'standard']);
|
||||
assertSilent(result);
|
||||
assert.match(result.stderr, /diagnostic only/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('preserves a nonzero exit code and stderr without synthesizing stdout', () => {
|
||||
const result = run(['post:test', 'hooks/nonzero.js', 'standard']);
|
||||
assert.strictEqual(result.status, 7);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.match(result.stderr, /blocked/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('preserves explicit stdout together with a nonzero exit code', () => {
|
||||
const result = run(['post:test', 'hooks/nonzero-output.js', 'standard']);
|
||||
assert.strictEqual(result.status, 7);
|
||||
assert.strictEqual(result.stdout, 'blocking output');
|
||||
assert.match(result.stderr, /blocked/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('preserves direct hook output that explicitly equals stdin', () => {
|
||||
const result = run(['post:test', 'hooks/direct-echo.js', 'standard']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, payload);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('preserves legacy hook output that explicitly equals stdin', () => {
|
||||
const result = run(['post:test', 'hooks/legacy-echo.js', 'standard']);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, payload);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('ECC_HOOK_INPUT_MAX_BYTES controls the runner cap and in-process context', () => {
|
||||
const result = run(
|
||||
['post:test', 'hooks/inspect-input.js', 'standard'],
|
||||
{ ECC_HOOK_INPUT_MAX_BYTES: '128' },
|
||||
'x'.repeat(256)
|
||||
);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(result.stdout), {
|
||||
raw: 'x'.repeat(128),
|
||||
bytes: 128,
|
||||
truncated: true,
|
||||
maxStdin: 128
|
||||
});
|
||||
assert.match(result.stderr, /stdin exceeded 128 bytes/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('stdin cap counts UTF-8 bytes at an exact multibyte boundary', () => {
|
||||
const input = String.fromCodePoint(0xe9).repeat(2);
|
||||
const result = run(
|
||||
['post:test', 'hooks/inspect-input.js', 'standard'],
|
||||
{ ECC_HOOK_INPUT_MAX_BYTES: '4' },
|
||||
input
|
||||
);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(result.stdout), {
|
||||
raw: input,
|
||||
bytes: 4,
|
||||
truncated: false,
|
||||
maxStdin: 4
|
||||
});
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('stdin cap discards an incomplete UTF-8 sequence at truncation', () => {
|
||||
const character = String.fromCodePoint(0xe9);
|
||||
const result = run(
|
||||
['post:test', 'hooks/inspect-input.js', 'standard'],
|
||||
{ ECC_HOOK_INPUT_MAX_BYTES: '3' },
|
||||
character.repeat(2)
|
||||
);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(result.stdout), {
|
||||
raw: character,
|
||||
bytes: 2,
|
||||
truncated: true,
|
||||
maxStdin: 3
|
||||
});
|
||||
assert.match(result.stderr, /stdin exceeded 3 bytes/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('invalid stdin caps warn and fall back without disabling hooks', () => {
|
||||
for (const configuredLimit of ['0', '-1', '1.5', 'not-a-number']) {
|
||||
const result = run(
|
||||
['post:test', 'hooks/inspect-input.js', 'standard'],
|
||||
{ ECC_HOOK_INPUT_MAX_BYTES: configuredLimit }
|
||||
);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(JSON.parse(result.stdout).maxStdin, 1024 * 1024);
|
||||
assert.match(result.stderr, /must be a positive safe integer/);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('stdin cap override cannot exceed the 1 MiB safety maximum', () => {
|
||||
const result = run(
|
||||
['post:test', 'hooks/undefined.js', 'standard'],
|
||||
{ ECC_HOOK_INPUT_MAX_BYTES: String(2 * 1024 * 1024) },
|
||||
'x'.repeat(1024 * 1024 + 1)
|
||||
);
|
||||
assertSilent(result);
|
||||
assert.match(result.stderr, /exceeds the 1 MiB safety maximum/);
|
||||
assert.match(result.stderr, /stdin exceeded 1048576 bytes/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('legacy hooks receive the resolved stdin cap and truncation flag', () => {
|
||||
const result = run(
|
||||
['post:test', 'hooks/legacy-inspect.js', 'standard'],
|
||||
{ ECC_HOOK_INPUT_MAX_BYTES: '128' },
|
||||
'x'.repeat(256)
|
||||
);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(result.stdout), {
|
||||
bytes: 128,
|
||||
truncated: '1',
|
||||
maxStdin: '128'
|
||||
});
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
for (const [eventName, entries] of Object.entries(hooksConfig.hooks)) {
|
||||
if (eventName === 'Stop') continue;
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
test(`${eventName}/${entry.id} registered disabled path stays silent`, () => {
|
||||
const result = runConfiguredHook(entry, { ECC_HOOKS_ENABLED: '0' });
|
||||
assertSilent(result);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionEndEntry = hooksConfig.hooks.SessionEnd.find(entry => entry.id === 'session:end:marker');
|
||||
if (
|
||||
test('SessionEnd unresolved-root fallback stays silent', () => {
|
||||
const result = runConfiguredHookWithMissingRoot(sessionEndEntry);
|
||||
assertSilent(result);
|
||||
assert.match(result.stderr, /lifecycle bootstrap unavailable/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
for (const hookId of [
|
||||
'pre:bash:dispatcher',
|
||||
'pre:powershell:gateguard-fact-force',
|
||||
'pre:config-protection',
|
||||
'pre:edit-write:gateguard-fact-force',
|
||||
'pre:mcp-health-check'
|
||||
]) {
|
||||
if (
|
||||
test(`${hookId} blocks registered PreToolUse input that was truncated`, () => {
|
||||
const entry = hooksConfig.hooks.PreToolUse.find(candidate => candidate.id === hookId);
|
||||
const toolInput = hookId === 'pre:powershell:gateguard-fact-force'
|
||||
? { command: `Remove-Item -Recurse -Force C:\\important\\data # ${'x'.repeat(256)}` }
|
||||
: {
|
||||
command: 'rm -rf /important/data',
|
||||
file_path: '/src/important.js',
|
||||
content: 'x'.repeat(256)
|
||||
};
|
||||
const input = JSON.stringify({
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: hookId === 'pre:powershell:gateguard-fact-force'
|
||||
? 'PowerShell'
|
||||
: hookId === 'pre:bash:dispatcher' ? 'Bash' : 'Write',
|
||||
tool_input: toolInput
|
||||
});
|
||||
const result = runConfiguredHook(entry, {
|
||||
ECC_DISABLED_HOOKS: '',
|
||||
ECC_DRY_RUN: '',
|
||||
ECC_HOOK_INPUT_MAX_BYTES: '64'
|
||||
}, input);
|
||||
assert.strictEqual(result.status, 2, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.match(result.stderr, /complete request|truncated payload/);
|
||||
assert.match(result.stderr, /bootstrap: stdin exceeded 64 bytes/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
for (const hookId of [
|
||||
'pre:powershell:gateguard-fact-force',
|
||||
'pre:edit-write:gateguard-fact-force'
|
||||
]) {
|
||||
for (const env of [
|
||||
{ ECC_GATEGUARD: 'off' },
|
||||
{ GATEGUARD_DISABLED: '1' }
|
||||
]) {
|
||||
if (
|
||||
test(`${hookId} recovery controls allow truncated input without stdout`, () => {
|
||||
const entry = hooksConfig.hooks.PreToolUse.find(
|
||||
candidate => candidate.id === hookId
|
||||
);
|
||||
const input = JSON.stringify({
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: hookId === 'pre:powershell:gateguard-fact-force' ? 'PowerShell' : 'Write',
|
||||
tool_input: { file_path: '/src/recovery.js', content: 'x'.repeat(256) }
|
||||
});
|
||||
const result = runConfiguredHook(entry, {
|
||||
ECC_DISABLED_HOOKS: '',
|
||||
ECC_DRY_RUN: '',
|
||||
ECC_HOOK_INPUT_MAX_BYTES: '64',
|
||||
...env
|
||||
}, input);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
test('MCP health recovery control allows truncated input without stdout', () => {
|
||||
const entry = hooksConfig.hooks.PreToolUse.find(
|
||||
candidate => candidate.id === 'pre:mcp-health-check'
|
||||
);
|
||||
const input = JSON.stringify({
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'mcp__unhealthy__search',
|
||||
tool_input: { query: 'x'.repeat(256) }
|
||||
});
|
||||
const result = runConfiguredHook(entry, {
|
||||
ECC_DISABLED_HOOKS: '',
|
||||
ECC_DRY_RUN: '',
|
||||
ECC_HOOK_INPUT_MAX_BYTES: '64',
|
||||
ECC_MCP_HEALTH_FAIL_OPEN: 'yes'
|
||||
}, input);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('SessionStart bootstrap unresolved-root fallback stays silent', () => {
|
||||
const result = runSessionStartBootstrapWithMissingRoot();
|
||||
assertSilent(result);
|
||||
assert.match(result.stderr, /could not resolve ECC plugin root/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('SessionStart bootstrap flushes large additionalContext output before exit', () => {
|
||||
const result = runSessionStartBootstrapWithLargeOutput('stdout', 0);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(Buffer.byteLength(result.stdout, 'utf8'), 512 * 1024);
|
||||
assert.match(result.stdout, /^x+$/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('SessionStart bootstrap flushes large non-zero exit output before exit', () => {
|
||||
const result = runSessionStartBootstrapWithLargeOutput('stderr', 7);
|
||||
assert.strictEqual(result.status, 7, result.stderr.slice(-200));
|
||||
assert.strictEqual(Buffer.byteLength(result.stderr, 'utf8'), 512 * 1024);
|
||||
assert.match(result.stderr, /^y+$/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('SessionStart bootstrap flushes both large output streams before exit', () => {
|
||||
const result = runSessionStartBootstrapWithLargeOutput('both', 9);
|
||||
assert.strictEqual(result.status, 9, result.stderr.slice(-200));
|
||||
assert.strictEqual(Buffer.byteLength(result.stdout, 'utf8'), 512 * 1024);
|
||||
assert.strictEqual(Buffer.byteLength(result.stderr, 'utf8'), 512 * 1024);
|
||||
assert.match(result.stdout, /^x+$/);
|
||||
assert.match(result.stderr, /^y+$/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
fs.rmSync(pluginRoot, { recursive: true, force: true });
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Regression tests for #2222: run-with-flags.js must fail open on >1MB stdin.
|
||||
* Regression tests for #2222: run-with-flags.js must not echo truncated stdin.
|
||||
*
|
||||
* Before the fix, every fallthrough path echoed the truncated payload to
|
||||
* stdout. The harness parses hook stdout as JSON, got a document cut
|
||||
@@ -61,7 +61,7 @@ if (
|
||||
assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, '', `stdout must be empty, got: ${result.stdout.slice(0, 120)}...`);
|
||||
assert.match(result.stderr, /stdin exceeded \d+ bytes for pre:write:doc-file-warning/);
|
||||
assert.match(result.stderr, /fail-open/);
|
||||
assert.match(result.stderr, /suppressing raw passthrough/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
@@ -88,15 +88,14 @@ if (
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('normal-sized payload still passes through unchanged', () => {
|
||||
test('normal-sized no-output hook stays silent', () => {
|
||||
const payload = JSON.stringify({
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: '/tmp/small.js', content: 'const x = 1;\n' }
|
||||
});
|
||||
const result = runRunner(['pre:write:doc-file-warning', 'scripts/hooks/doc-file-warning.js', 'standard,strict'], payload);
|
||||
assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}: ${result.stderr}`);
|
||||
assert.ok(result.stdout.length > 0, 'normal payloads keep the pass-through behavior');
|
||||
JSON.parse(result.stdout); // stdout must remain valid JSON
|
||||
assert.strictEqual(result.stdout, '', 'silent hooks must not echo normal payloads');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
@@ -120,35 +119,32 @@ if (
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('payload just under the cap echoes through completely (no 64KB pipe cut)', () => {
|
||||
// process.exit() right after stdout.write() used to drop everything past
|
||||
// the ~64KB pipe buffer, cutting the echoed JSON mid-stream.
|
||||
test('missing-args path stays silent just under the cap', () => {
|
||||
const content = 'y'.repeat(MAX_STDIN - 1024);
|
||||
const payload = JSON.stringify({ tool_name: 'Write', tool_input: { file_path: '/tmp/edge.md', content } });
|
||||
assert.ok(payload.length < MAX_STDIN, 'fixture must stay under the stdin cap');
|
||||
const result = runRunner([], payload);
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout.length, payload.length, 'echo must not be cut at the pipe buffer');
|
||||
assert.strictEqual(result.stdout, payload, 'sub-cap payloads still echo through fallthrough paths');
|
||||
assert.strictEqual(result.stdout, '', 'missing-args path must not echo sub-cap payloads');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('disabled-hook passthrough of a >64KB payload stays valid JSON', () => {
|
||||
test('disabled hook stays silent for a >64KB payload', () => {
|
||||
const payload = JSON.stringify({
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: '/tmp/medium.md', content: 'z'.repeat(256 * 1024) }
|
||||
});
|
||||
const result = runRunner(['pre:write:doc-file-warning', 'scripts/hooks/doc-file-warning.js', 'standard,strict'], payload, { ECC_DISABLED_HOOKS: 'pre:write:doc-file-warning' });
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, payload);
|
||||
JSON.parse(result.stdout);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
/**
|
||||
* Regression tests for #2090: "Stop hook error: JSON validation failed".
|
||||
*
|
||||
* Stop hooks follow the ECC pass-through convention (echo stdin on stdout).
|
||||
* The Stop payload carries `last_assistant_message`, which can be large; any
|
||||
* hook that caps stdin and echoes the capped string emits a JSON document cut
|
||||
* mid-stream, which the harness reports as a Stop hook JSON validation
|
||||
* failure. Worst offender: cost-tracker capped stdin at 64KB, so any Stop
|
||||
* payload with a >64KB final assistant message broke the whole Stop chain.
|
||||
* Stop payloads carry `last_assistant_message`, which can be large. Silent
|
||||
* wrapper paths must emit nothing; explicit hook output must remain complete
|
||||
* and valid JSON so the harness never sees a truncated document.
|
||||
*
|
||||
* Contract under test: for every Stop hook, stdout is either empty or valid
|
||||
* JSON, and the exit code is 0 — for realistic large payloads and for
|
||||
@@ -115,6 +112,25 @@ function runRegisteredStopHook(entry, input, envOverrides = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function runRegisteredStopHookWithMissingRoot(entry, input) {
|
||||
const missingRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-missing-root-'));
|
||||
fs.rmSync(missingRoot, { recursive: true, force: true });
|
||||
return spawnSync(entry.hooks[0].command, {
|
||||
input,
|
||||
encoding: 'utf8',
|
||||
cwd: workDir,
|
||||
env: {
|
||||
...hookEnv(),
|
||||
CLAUDE_PLUGIN_ROOT: missingRoot,
|
||||
ECC_PLUGIN_ROOT: missingRoot
|
||||
},
|
||||
shell: true,
|
||||
timeout: SUBPROCESS_TIMEOUT_MS,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
}
|
||||
|
||||
function assertStdoutContract(result, label) {
|
||||
assert.strictEqual(result.status, 0, `${label}: expected exit 0, got ${result.status}: ${result.stderr}`);
|
||||
if (result.stdout.length > 0) {
|
||||
@@ -170,13 +186,11 @@ let failed = 0;
|
||||
// runner path, making the harness report "JSON validation failed".
|
||||
const realisticPayload = stopPayload(100 * 1024);
|
||||
|
||||
// Exercise the command users actually run from hooks.json. The runner already
|
||||
// flushes large stdout before exiting, but the outer lifecycle wrapper used to
|
||||
// call process.exit() immediately after forwarding it, cutting the JSON at the
|
||||
// OS pipe buffer and reintroducing #2222 above the tested runner layer.
|
||||
// Exercise the command users actually run from hooks.json. Disabled and
|
||||
// no-opinion registered hooks must not copy their Stop payload to stdout.
|
||||
for (const entry of hooksConfig.hooks.Stop) {
|
||||
if (
|
||||
test(`${entry.id} registered wrapper flushes a 100KB Stop payload`, () => {
|
||||
test(`${entry.id} disabled registered wrapper stays silent for a 100KB Stop payload`, () => {
|
||||
const startedAt = process.hrtime.bigint();
|
||||
const result = runRegisteredStopHook(entry, realisticPayload);
|
||||
const elapsedMs = Math.round(Number(process.hrtime.bigint() - startedAt) / 1e6);
|
||||
@@ -185,11 +199,20 @@ for (const entry of hooksConfig.hooks.Stop) {
|
||||
0,
|
||||
result.status === 0 ? undefined : `${entry.id}: expected exit 0; ${formatSpawnFailure(result, elapsedMs)}`
|
||||
);
|
||||
assert.ok(
|
||||
result.stdout === realisticPayload,
|
||||
`${entry.id}: registered wrapper must echo ${realisticPayload.length} characters uncut (got ${result.stdout.length})`
|
||||
);
|
||||
JSON.parse(result.stdout);
|
||||
assert.strictEqual(result.stdout, '', `${entry.id}: disabled wrapper must stay silent`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
for (const entry of hooksConfig.hooks.Stop) {
|
||||
if (
|
||||
test(`${entry.id} unresolved-root fallback stays silent`, () => {
|
||||
const result = runRegisteredStopHookWithMissingRoot(entry, realisticPayload);
|
||||
assert.strictEqual(result.status, 0, `${entry.id}: expected exit 0, got ${result.status}: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, '', `${entry.id}: unresolved-root fallback must stay silent`);
|
||||
assert.match(result.stderr, /lifecycle bootstrap unavailable/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
@@ -199,15 +222,85 @@ for (const entry of hooksConfig.hooks.Stop) {
|
||||
const representativeStopEntry = hooksConfig.hooks.Stop.find(
|
||||
entry => entry.id === 'stop:cost-tracker'
|
||||
);
|
||||
const CALLBACK_FLUSH_WRAPPER = 'const finish=(out,err,code)=>{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};';
|
||||
const consoleLogStopEntry = hooksConfig.hooks.Stop.find(
|
||||
entry => entry.id === 'stop:check-console-log'
|
||||
);
|
||||
|
||||
if (
|
||||
test('all registered Stop wrappers keep the large-output flush contract', () => {
|
||||
for (const entry of hooksConfig.hooks.Stop) {
|
||||
assert.match(entry.hooks[0].command, /maxBuffer:16\*1024\*1024/);
|
||||
test('enabled registered Stop wrapper suppresses legacy raw-input passthrough', () => {
|
||||
const result = runRegisteredStopHook(consoleLogStopEntry, realisticPayload, {
|
||||
ECC_DISABLED_HOOKS: ''
|
||||
});
|
||||
assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, '', 'registered Stop boundary must suppress raw-input output');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('registered Stop wrapper applies a configured byte cap', () => {
|
||||
const result = runRegisteredStopHook(representativeStopEntry, realisticPayload, {
|
||||
ECC_HOOK_INPUT_MAX_BYTES: '64'
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.match(result.stderr, /lifecycle stdin exceeded 64 bytes/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('registered Plan Canvas Stop wrapper preserves an explicit block decision', () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-plan-canvas-stop-'));
|
||||
const artifact = path.join(workDir, 'feature.plan.md');
|
||||
const timestamp = '2026-01-01T00:00:00.000Z';
|
||||
const state = {
|
||||
sessions: {
|
||||
aaaaaaaaaaaa: {
|
||||
key: 'aaaaaaaaaaaa',
|
||||
file: artifact,
|
||||
status: 'feedback',
|
||||
chat: [],
|
||||
pendingFeedback: [
|
||||
{ id: 'feedback-1', kind: 'chat', text: 'move phase 2 up', at: timestamp }
|
||||
],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
}
|
||||
},
|
||||
feedbackCounter: 1
|
||||
};
|
||||
try {
|
||||
fs.writeFileSync(path.join(stateDir, 'sessions.json'), JSON.stringify(state));
|
||||
const entry = hooksConfig.hooks.Stop.find(candidate => candidate.id === 'stop:plan-canvas-pending');
|
||||
const input = JSON.stringify({ cwd: workDir, hook_event_name: 'Stop', stop_hook_active: false });
|
||||
const result = runRegisteredStopHook(entry, input, {
|
||||
ECC_DISABLED_HOOKS: '',
|
||||
ECC_PLAN_CANVAS_STATE_DIR: stateDir
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const output = JSON.parse(result.stdout);
|
||||
assert.strictEqual(output.decision, 'block');
|
||||
assert.match(output.reason, /move phase 2 up/);
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
if (
|
||||
test('all registered lifecycle hooks use the bounded shared bootstrap', () => {
|
||||
const lifecycleEntries = [
|
||||
...hooksConfig.hooks.Stop,
|
||||
...hooksConfig.hooks.SessionEnd
|
||||
];
|
||||
for (const entry of lifecycleEntries) {
|
||||
assert.ok(
|
||||
entry.hooks[0].command.includes(CALLBACK_FLUSH_WRAPPER),
|
||||
`${entry.id}: wrapper must wait for stdout and stderr callbacks before exiting`
|
||||
entry.hooks[0].command.includes('scripts/hooks/lifecycle-hook-bootstrap.js'),
|
||||
`${entry.id}: expected the shared lifecycle bootstrap`
|
||||
);
|
||||
}
|
||||
})
|
||||
@@ -216,17 +309,13 @@ if (
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('registered Stop wrapper flushes a 100KB dry-run payload', () => {
|
||||
test('registered Stop wrapper stays silent for a 100KB dry-run payload', () => {
|
||||
const result = runRegisteredStopHook(representativeStopEntry, realisticPayload, {
|
||||
ECC_DISABLED_HOOKS: '',
|
||||
ECC_DRY_RUN: '1'
|
||||
});
|
||||
assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}: ${result.stderr}`);
|
||||
assert.ok(
|
||||
result.stdout === realisticPayload,
|
||||
`dry-run wrapper must echo ${realisticPayload.length} characters uncut (got ${result.stdout.length})`
|
||||
);
|
||||
JSON.parse(result.stdout);
|
||||
assert.strictEqual(result.stdout, '', 'dry-run wrapper must stay silent');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
@@ -239,26 +328,17 @@ const multibytePayload = stopPayload(400 * 1024, '한');
|
||||
assert.ok(multibytePayload.length < MAX_STDIN, 'fixture must stay below the runner character cap');
|
||||
assert.ok(Buffer.byteLength(multibytePayload) > MAX_STDIN, 'fixture must exceed the default byte buffer');
|
||||
|
||||
// Every registered command uses the same generated wrapper, verified above.
|
||||
// Exercise the multi-megabyte byte-buffer edge once so the test does not
|
||||
// amplify hosted-runner load by serializing the identical payload seven times.
|
||||
if (
|
||||
test('registered Stop wrapper preserves a multibyte sub-cap payload', () => {
|
||||
const result = runRegisteredStopHook(representativeStopEntry, multibytePayload);
|
||||
assert.strictEqual(
|
||||
result.status,
|
||||
0,
|
||||
`expected exit 0, got ${result.status}: ${result.stderr}`
|
||||
);
|
||||
assert.ok(
|
||||
result.stdout === multibytePayload,
|
||||
`registered wrapper must echo ${Buffer.byteLength(multibytePayload)} bytes uncut (got ${Buffer.byteLength(result.stdout)})`
|
||||
);
|
||||
JSON.parse(result.stdout);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
for (const entry of hooksConfig.hooks.Stop) {
|
||||
if (
|
||||
test(`${entry.id} disabled registered wrapper stays silent for a multibyte payload`, () => {
|
||||
const result = runRegisteredStopHook(entry, multibytePayload);
|
||||
assert.strictEqual(result.status, 0, `${entry.id}: expected exit 0, got ${result.status}: ${result.stderr}`);
|
||||
assert.strictEqual(result.stdout, '', `${entry.id}: disabled wrapper must stay silent`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
for (const [hookId, script] of STOP_HOOKS) {
|
||||
if (
|
||||
@@ -266,7 +346,7 @@ for (const [hookId, script] of STOP_HOOKS) {
|
||||
const result = runViaRunner(hookId, script, realisticPayload);
|
||||
assertStdoutContract(result, hookId);
|
||||
if (result.stdout.length > 0) {
|
||||
assert.strictEqual(result.stdout, realisticPayload, `${hookId}: pass-through must echo the payload uncut`);
|
||||
assert.strictEqual(result.stdout, realisticPayload, `${hookId}: explicit raw output must remain complete`);
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -302,6 +382,7 @@ if (
|
||||
0,
|
||||
`wrapper must preserve oversized-input suppression (got ${result.stdout.length} characters)`
|
||||
);
|
||||
assert.match(result.stderr, /lifecycle stdin exceeded 1048576 bytes/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
@@ -371,5 +452,6 @@ try {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
|
||||
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
||||
@@ -94,7 +94,7 @@ function runTests() {
|
||||
result.stderr.includes('target=/tmp/test.md'),
|
||||
`Expected stderr to contain target file path, got: ${result.stderr}`
|
||||
);
|
||||
assert.strictEqual(result.stdout, input, 'Expected stdin to be passed through unchanged');
|
||||
assert.strictEqual(result.stdout, '', 'Dry-run hooks must not echo stdin');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('flushes a large dry-run preview when oversized stdout is suppressed', () => {
|
||||
@@ -151,7 +151,7 @@ function runTests() {
|
||||
result.stderr.includes('command=git commit --no-verify'),
|
||||
`Expected stderr to contain command, got: ${result.stderr}`
|
||||
);
|
||||
assert.strictEqual(result.stdout, input, 'Expected stdin to be passed through unchanged');
|
||||
assert.strictEqual(result.stdout, '', 'Dry-run hooks must not echo stdin');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('dry-run preview handles non-JSON stdin gracefully', () => {
|
||||
@@ -180,7 +180,7 @@ function runTests() {
|
||||
!result.stderr.includes('tool='),
|
||||
'Expected no tool= when stdin is not JSON'
|
||||
);
|
||||
assert.strictEqual(result.stdout, input, 'Expected stdin to be passed through unchanged');
|
||||
assert.strictEqual(result.stdout, '', 'Dry-run hooks must not echo stdin');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('dry-run preview handles empty stdin gracefully', () => {
|
||||
@@ -285,6 +285,8 @@ function runTests() {
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,11 @@ const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')
|
||||
).version;
|
||||
|
||||
const { resolveEccRoot, INLINE_RESOLVE } = require('../../scripts/lib/resolve-ecc-root');
|
||||
const {
|
||||
resolveEccRoot,
|
||||
normalizePluginRootForPlatform,
|
||||
INLINE_RESOLVE
|
||||
} = require('../../scripts/lib/resolve-ecc-root');
|
||||
|
||||
// Sentinel ECC skill that resolveEccRoot() requires (alongside the script tree)
|
||||
// before accepting a root for skill consumers. Kept in sync with the module's
|
||||
@@ -401,6 +405,17 @@ function runTests() {
|
||||
assert.ok(INLINE_RESOLVE.length > 50, 'Should be a substantial inline expression');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('normalizes Git Bash drive roots for Windows lifecycle loaders', () => {
|
||||
assert.strictEqual(
|
||||
normalizePluginRootForPlatform('/c/Users/x/.claude/plugins/ecc', 'win32'),
|
||||
'C:/Users/x/.claude/plugins/ecc'
|
||||
);
|
||||
assert.strictEqual(
|
||||
normalizePluginRootForPlatform('/workspace/ecc', 'win32'),
|
||||
'/workspace/ecc'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('INLINE_RESOLVE does not contain spread, nested arrays, or escaped quotes', () => {
|
||||
assert.ok(!INLINE_RESOLVE.includes('...'));
|
||||
assert.ok(!INLINE_RESOLVE.includes('[['));
|
||||
|
||||
@@ -100,7 +100,7 @@ function buildEccSkeleton(repoRoot) {
|
||||
const hooksDir = path.join(root, "scripts", "hooks")
|
||||
fs.mkdirSync(hooksDir, { recursive: true })
|
||||
|
||||
for (const name of ["run-with-flags.js", "session-end-marker.js", "pretooluse-visible-output.js"]) {
|
||||
for (const name of ["hook-input.js", "run-with-flags.js", "session-end-marker.js", "pretooluse-visible-output.js"]) {
|
||||
fs.cpSync(path.join(repoRoot, "scripts", "hooks", name), path.join(hooksDir, name))
|
||||
}
|
||||
fs.cpSync(path.join(repoRoot, "scripts", "lib"), path.join(root, "scripts", "lib"), { recursive: true })
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
const assert = require("assert")
|
||||
const fs = require("fs")
|
||||
const os = require("os")
|
||||
const path = require("path")
|
||||
const { spawnSync } = require("child_process")
|
||||
const { getNpmPackEntry } = require("../lib/npm-pack-output")
|
||||
@@ -46,6 +47,72 @@ function main() {
|
||||
assert.strictEqual(result.status, 0, result.stderr)
|
||||
assert.ok(fs.existsSync(distEntry), ".opencode/dist/index.js should exist after build")
|
||||
}],
|
||||
["package.json declares a resolvable OpenCode plugin entry", () => {
|
||||
assert.strictEqual(packageJson.main, ".opencode/dist/index.js")
|
||||
assert.ok(packageJson.exports, "package.json must declare an exports map")
|
||||
assert.deepStrictEqual(packageJson.exports["."], {
|
||||
types: "./.opencode/dist/index.d.ts",
|
||||
import: "./.opencode/dist/index.js",
|
||||
default: "./.opencode/dist/index.js",
|
||||
})
|
||||
}],
|
||||
["installed package resolves and imports its root module by name", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-entry-"))
|
||||
try {
|
||||
fs.mkdirSync(path.join(tempDir, "node_modules"), { recursive: true })
|
||||
fs.symlinkSync(
|
||||
repoRoot,
|
||||
path.join(tempDir, "node_modules", "ecc-universal"),
|
||||
process.platform === "win32" ? "junction" : "dir"
|
||||
)
|
||||
const probe = `
|
||||
const resolved = import.meta.resolve("ecc-universal")
|
||||
if (!resolved.endsWith("/.opencode/dist/index.js")) {
|
||||
throw new Error("unexpected entry resolution: " + resolved)
|
||||
}
|
||||
const mod = await import("ecc-universal")
|
||||
if (Object.keys(mod).join(",") !== "default" || typeof mod.default !== "function") {
|
||||
throw new Error("root module must export exactly the plugin function")
|
||||
}
|
||||
`
|
||||
const probePath = path.join(tempDir, "probe.mjs")
|
||||
fs.writeFileSync(probePath, probe)
|
||||
const result = spawnSync(process.execPath, [probePath], {
|
||||
cwd: tempDir,
|
||||
encoding: "utf8",
|
||||
})
|
||||
assert.strictEqual(result.status, 0, result.stderr)
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}],
|
||||
["OpenCode TypeScript sources resolve their relative imports in place", () => {
|
||||
const opencodeDir = path.join(repoRoot, ".opencode")
|
||||
const sourceFiles = []
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== "node_modules" && entry.name !== "dist") walk(entryPath)
|
||||
} else if (entry.name.endsWith(".ts")) {
|
||||
sourceFiles.push(entryPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(opencodeDir)
|
||||
assert.ok(sourceFiles.length > 0, "expected OpenCode TypeScript sources")
|
||||
const unresolved = []
|
||||
for (const sourceFile of sourceFiles) {
|
||||
const source = fs.readFileSync(sourceFile, "utf8")
|
||||
for (const match of source.matchAll(/(?:from|import)\s*\(?\s*"(\.[^"]+)"/g)) {
|
||||
const target = path.resolve(path.dirname(sourceFile), match[1])
|
||||
if (!fs.existsSync(target)) {
|
||||
unresolved.push(`${path.relative(repoRoot, sourceFile)} -> ${match[1]}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(unresolved, [])
|
||||
}],
|
||||
["built OpenCode entry exports only the plugin function", () => {
|
||||
const check = `
|
||||
const assert = require("assert")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const {
|
||||
collectInteractiveOptions,
|
||||
@@ -60,25 +60,56 @@ function quoteShellArgument(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function runGuidedPtyFixture(answers) {
|
||||
if (process.platform === 'win32') return null;
|
||||
function stripPtyControlBytes(value) {
|
||||
return value
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\r/g, '');
|
||||
}
|
||||
|
||||
function runGuidedPtyFixture(exchanges) {
|
||||
if (process.platform === 'win32') return Promise.resolve(null);
|
||||
const command = [process.execPath, guidedPtyFixture];
|
||||
const scriptArgs = process.platform === 'darwin'
|
||||
? ['-q', '-e', '/dev/null', ...command]
|
||||
: ['-q', '-e', '-c', command.map(quoteShellArgument).join(' '), '/dev/null'];
|
||||
const pseudoTerminalCommand = ['script', ...scriptArgs]
|
||||
.map(quoteShellArgument)
|
||||
.join(' ');
|
||||
const answerCommands = answers
|
||||
.map(answer => `sleep 0.35; printf '%s\\n' ${quoteShellArgument(answer)}`)
|
||||
.join('; ');
|
||||
return spawnSync('sh', ['-c', `(${answerCommands}; sleep 0.1) | ${pseudoTerminalCommand}`], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
return new Promise((resolve, reject) => {
|
||||
// Answers go through cat so script reads a plain pipe: spawned stdio is
|
||||
// a socketpair, and the macOS script(1) refuses a socket stdin.
|
||||
const feeder = `cat | ${['script', ...scriptArgs].map(quoteShellArgument).join(' ')}`;
|
||||
const child = spawn('sh', ['-c', feeder], { cwd: repoRoot });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let sent = 0;
|
||||
let settled = false;
|
||||
const finish = callback => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
callback();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
finish(() => reject(new Error('guided PTY fixture timed out')));
|
||||
}, 15000);
|
||||
const feed = () => {
|
||||
// Answer only once the matching prompt is on screen. Fixed sleeps
|
||||
// typed answers ahead of readline; under CI load the first answer
|
||||
// could land before the interface listened, shifting every later
|
||||
// answer onto the wrong question (ubuntu Node 18 npm job).
|
||||
const visible = stripPtyControlBytes(stdout + stderr);
|
||||
while (sent < exchanges.length && visible.includes(exchanges[sent].expect)) {
|
||||
child.stdin.write(`${exchanges[sent].send}\n`);
|
||||
sent += 1;
|
||||
}
|
||||
if (sent === exchanges.length) child.stdin.end();
|
||||
};
|
||||
child.stdout.on('data', data => { stdout += data; feed(); });
|
||||
child.stderr.on('data', data => { stderr += data; feed(); });
|
||||
child.on('error', error => finish(() => reject(error)));
|
||||
child.on('close', (status, signal) => finish(() => resolve({ status, signal, stdout, stderr })));
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('\n=== Guided multi-harness CLI tests ===\n');
|
||||
|
||||
@@ -175,14 +206,17 @@ function runGuidedPtyFixture(answers) {
|
||||
);
|
||||
});
|
||||
|
||||
await test('real PTY shows every all-harness question and applies after visible yes', () => {
|
||||
const result = runGuidedPtyFixture(['all', '1', '3', '2', 'y']);
|
||||
await test('real PTY shows every all-harness question and applies after visible yes', async () => {
|
||||
const result = await runGuidedPtyFixture([
|
||||
{ expect: 'Choose one or more (for example 1,3 or all):', send: 'all' },
|
||||
{ expect: 'Choose [Recommended: user] (one option only):', send: '1' },
|
||||
{ expect: 'Choose [Recommended: standard] (one option only):', send: '3' },
|
||||
{ expect: 'Choose [Recommended: core] (one option only):', send: '2' },
|
||||
{ expect: 'Apply ECC to these harnesses? [y/N]:', send: 'y' },
|
||||
]);
|
||||
if (result === null) return;
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const visible = `${result.stdout}${result.stderr}`
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\r/g, '');
|
||||
const visible = stripPtyControlBytes(`${result.stdout}${result.stderr}`);
|
||||
const orderedPrompts = [
|
||||
'Choose one or more (for example 1,3 or all):',
|
||||
'Choose [Recommended: user] (one option only):',
|
||||
|
||||
@@ -777,6 +777,8 @@ async function main() {
|
||||
},
|
||||
});
|
||||
assert.strictEqual(initialized.id, 0);
|
||||
assert.match(initialized.result.instructions, /host-bound harness identity/);
|
||||
assert.match(initialized.result.instructions, /does not provide OAuth/);
|
||||
await service.handle({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
|
||||
Reference in New Issue
Block a user