Merge branch 'main' into fix/prepush-venv-pytest

This commit is contained in:
Affaan Mustafa
2026-09-18 21:03:34 -04:00
committed by GitHub
77 changed files with 1928 additions and 330 deletions
+104
View File
@@ -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
// ==========================================
+43
View File
@@ -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' } };
+3 -6
View File
@@ -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 });
+2 -15
View File
@@ -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++;
+1 -1
View File
@@ -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 });
+129
View File
@@ -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;
});
+4 -2
View File
@@ -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)}...`);
}
}
}
+12
View File
@@ -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 {
+144 -7
View File
@@ -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);
+10 -14
View File
@@ -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);
+132 -50
View File
@@ -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);
+5 -3
View File
@@ -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);
}
+16 -1
View File
@@ -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('[['));
+1 -1
View File
@@ -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 })
+67
View File
@@ -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")
+54 -20
View File
@@ -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):',
+2
View File
@@ -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',