mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-08 07:37:48 +02:00
Merge pull request #2380 from chs0813/fix/plugin-hook-bootstrap-no-echo
fix(hooks): do not echo raw input from plugin-hook-bootstrap.js
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Regression tests for plugin-hook-bootstrap.js raw-echo bloat.
|
||||
*
|
||||
* Before the fix, every fallthrough path in plugin-hook-bootstrap.js
|
||||
* (the actual entry point used by ECC plugin hooks, NOT run-with-flags.js)
|
||||
* echoed the full raw hook input JSON to stdout. For a typical
|
||||
* PostToolUse:Edit payload this is 10-130 KB of tool_input + tool_response
|
||||
* per tool call. The harness then wrote that stdout into the session
|
||||
* transcript as a hook_success attachment, ballooning 51 transcripts
|
||||
* to a combined 1.06 GB (89% of which was raw-echo bloat).
|
||||
*
|
||||
* The fix removes the 4 echo-raw sites in plugin-hook-bootstrap.js:
|
||||
* - line 137: missing mode/relPath/rootDir
|
||||
* - line 149: unknown mode
|
||||
* - line 154: catch on spawn failure
|
||||
* - line 31: passthrough() default when hook outputs nothing
|
||||
*
|
||||
* For each, we emit empty stdout and a stderr explanation. The harness
|
||||
* then falls back to the tool_use's original result, mirroring the
|
||||
* pattern already shipped in #2240 (bash-hook-dispatcher.js) and #2227
|
||||
* (run-with-flags.js truncation path).
|
||||
*
|
||||
* Related:
|
||||
* - #2222 / #2227 — fixed the *truncated* path of run-with-flags.js
|
||||
* - #2239 / #2240 — fixed the same bug in bash-hook-dispatcher.js
|
||||
* - #1575 — "token limit so fast" (symptom caused in part by this)
|
||||
*
|
||||
* Fixtures live under a unique os.tmpdir() directory (per reviewer feedback
|
||||
* on #2380 — keep temp fixture files out of the live scripts/hooks/ tree and
|
||||
* avoid collisions across parallel/cross-platform test runs).
|
||||
*/
|
||||
|
||||
'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 bootstrap = path.join(repoRoot, 'scripts', 'hooks', 'plugin-hook-bootstrap.js');
|
||||
const { isRawPassthrough } = require(bootstrap);
|
||||
const FIXTURE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-pr2380-fixtures-'));
|
||||
|
||||
function cleanupFixtureDir() {
|
||||
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.once('exit', cleanupFixtureDir);
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runBootstrap(args, input, env) {
|
||||
return spawnSync('node', [bootstrap, ...args], {
|
||||
input,
|
||||
encoding: 'utf8',
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, ...(env || {}) },
|
||||
timeout: 30000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
}
|
||||
|
||||
function runHookEntry(args, input, env) {
|
||||
const loader = `const s=${JSON.stringify(bootstrap)};process.argv.splice(1,0,s);require(s)`;
|
||||
return spawnSync(process.execPath, ['-e', loader, ...args], {
|
||||
input,
|
||||
encoding: 'utf8',
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, ...(env || {}) },
|
||||
timeout: 30000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
}
|
||||
|
||||
function realisticPostToolUseEditPayload() {
|
||||
return JSON.stringify({
|
||||
session_id: 'test-session',
|
||||
transcript_path: '/tmp/test.jsonl',
|
||||
cwd: '/tmp',
|
||||
permission_mode: 'auto',
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Edit',
|
||||
tool_input: {
|
||||
file_path: '/tmp/example.ts',
|
||||
old_string: 'a'.repeat(200),
|
||||
new_string: 'b'.repeat(200)
|
||||
},
|
||||
tool_response: { filePath: '/tmp/example.ts', diff: 'c'.repeat(100 * 1024) },
|
||||
tool_use_id: 'call_test_1'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\nplugin-hook-bootstrap raw-echo (no bloat) tests:');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// --- Bug site #1: line 137 (missing args) ---
|
||||
if (
|
||||
test('fallthrough 1: missing mode emits empty stdout (no raw echo)', () => {
|
||||
const payload = realisticPostToolUseEditPayload();
|
||||
const result = runBootstrap([], payload, {
|
||||
CLAUDE_PLUGIN_ROOT: repoRoot
|
||||
});
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '', 'missing-args path must NOT echo raw input (was ' + result.stdout.length + ' bytes)');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// --- Bug site #2: line 149 (unknown mode) ---
|
||||
if (
|
||||
test('fallthrough 2: unknown mode emits empty stdout (no raw echo)', () => {
|
||||
const payload = realisticPostToolUseEditPayload();
|
||||
const result = runBootstrap(['bogus-mode', path.join(FIXTURE_DIR, 'noop-hook-fixture.js')], payload, {
|
||||
CLAUDE_PLUGIN_ROOT: repoRoot
|
||||
});
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '', 'unknown-mode path must NOT echo raw input (was ' + result.stdout.length + ' bytes)');
|
||||
assert.match(result.stderr, /unknown bootstrap mode/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// --- Bug site #3: line 31 (passthrough default) — THE CORE BUG ---
|
||||
// This is what fires on EVERY successful hook call where the hook script
|
||||
// itself didn't write to stdout. The default `passthrough` behavior is
|
||||
// to echo raw input — which is the bulk of the bloat.
|
||||
if (
|
||||
test('fallthrough 3: silent hook does NOT echo raw input (the core bug)', () => {
|
||||
const payload = realisticPostToolUseEditPayload();
|
||||
// A no-op node hook that reads stdin and exits silently. It lives in the
|
||||
// unique temporary fixture root, not in the live scripts/hooks/ tree.
|
||||
const noopHookPath = path.join(FIXTURE_DIR, 'noop-hook-fixture.js');
|
||||
fs.writeFileSync(noopHookPath, "process.stdin.resume(); process.stdin.on('end', () => process.exit(0));");
|
||||
try {
|
||||
const result = runBootstrap(['node', path.basename(noopHookPath)], payload, {
|
||||
CLAUDE_PLUGIN_ROOT: FIXTURE_DIR
|
||||
});
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '', 'silent hook must NOT echo raw input (was ' + result.stdout.length + ' bytes)');
|
||||
} finally {
|
||||
fs.unlinkSync(noopHookPath);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// --- Bug site #4: tool_response leak guard (the user-visible symptom) ---
|
||||
if (
|
||||
test('fallthrough 4: tool_response contents never leak into stdout', () => {
|
||||
const marker = 'PAYLOAD_MARKER_DO_NOT_LEAK_X9Z42';
|
||||
const payload = JSON.stringify({
|
||||
session_id: 'test',
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/tmp/x', old_string: 'A', new_string: 'B' },
|
||||
tool_response: { filePath: '/tmp/x', leaked: marker, diff: 'x'.repeat(50 * 1024) }
|
||||
});
|
||||
const result = runBootstrap([], payload, {
|
||||
CLAUDE_PLUGIN_ROOT: repoRoot
|
||||
});
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.ok(!result.stdout.includes(marker), 'tool_response contents must not appear in stdout');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// --- GREEN-side: behavior we want preserved ---
|
||||
if (
|
||||
test('GREEN: hook that outputs JSON is passed through unchanged', () => {
|
||||
// When the hook legitimately produces output (e.g., PreToolUse
|
||||
// additionalContext), we must preserve that output verbatim.
|
||||
const fixturePath = path.join(FIXTURE_DIR, 'echo-fixture.js');
|
||||
const expectedOutput = '{"hookSpecificOutput":{"permissionDecision":"allow"}}\n';
|
||||
fs.writeFileSync(
|
||||
fixturePath,
|
||||
"process.stdin.resume(); process.stdin.on('end', () => { process.stdout.write('" + expectedOutput.replace(/\n/g, '\\n') + "'); process.exit(0); });"
|
||||
);
|
||||
try {
|
||||
const payload = realisticPostToolUseEditPayload();
|
||||
const result = runBootstrap(['node', path.basename(fixturePath)], payload, {
|
||||
CLAUDE_PLUGIN_ROOT: FIXTURE_DIR
|
||||
});
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.ok(result.stdout.length > 0, 'hook that produced output should have non-empty stdout');
|
||||
// Must not contain the raw input — only the hook's own output
|
||||
assert.ok(!result.stdout.includes('tool_response'), 'when hook outputs its own stdout, raw input must not also be echoed');
|
||||
} finally {
|
||||
fs.unlinkSync(fixturePath);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// --- THE CORE ECC PATTERN: most ECC hooks do `process.stdout.write(run(data))`
|
||||
// where run(data) returns the raw input unchanged. Bootstrap must detect
|
||||
// this and emit empty stdout instead of writing raw back. ---
|
||||
if (
|
||||
test('CORE ECC PATTERN: hook returning raw input as stdout is suppressed', () => {
|
||||
// Simulate the post-edit-accumulator pattern: read stdin, return it
|
||||
// unchanged via process.stdout.write. This is THE dominant source of
|
||||
// transcript bloat — 12+ ECC hook scripts use this exact pattern.
|
||||
const fixturePath = path.join(FIXTURE_DIR, 'passthrough-fixture.js');
|
||||
fs.writeFileSync(
|
||||
fixturePath,
|
||||
"let d=''; process.stdin.setEncoding('utf8'); process.stdin.on('data', c => d += c); process.stdin.on('end', () => { process.stdout.write(d); process.exit(0); });"
|
||||
);
|
||||
try {
|
||||
const payload = realisticPostToolUseEditPayload();
|
||||
const result = runBootstrap(['node', path.basename(fixturePath)], payload, {
|
||||
CLAUDE_PLUGIN_ROOT: FIXTURE_DIR
|
||||
});
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '', 'hook that returned raw input as stdout must be suppressed (was ' + result.stdout.length + ' bytes)');
|
||||
assert.match(result.stderr, /returned raw input as stdout/, 'stderr should explain the suppression');
|
||||
} finally {
|
||||
fs.unlinkSync(fixturePath);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('64 KiB passthrough sentinel is measured in UTF-8 bytes', () => {
|
||||
const fixturePath = path.join(FIXTURE_DIR, 'multibyte-prefix-fixture.js');
|
||||
fs.writeFileSync(
|
||||
fixturePath,
|
||||
"let d=''; process.stdin.setEncoding('utf8'); process.stdin.on('data', c => d += c); process.stdin.on('end', () => process.stdout.write(d.slice(0, 32768)));"
|
||||
);
|
||||
try {
|
||||
const payload = `${'é'.repeat(32768)}tail`;
|
||||
const result = runBootstrap(['node', path.basename(fixturePath)], payload, {
|
||||
CLAUDE_PLUGIN_ROOT: FIXTURE_DIR
|
||||
});
|
||||
assert.strictEqual(Buffer.byteLength(payload.slice(0, 32768), 'utf8'), 64 * 1024);
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '', 'a 64 KiB UTF-8 prefix of raw input must be suppressed');
|
||||
assert.match(result.stderr, /returned raw input as stdout/);
|
||||
} finally {
|
||||
fs.unlinkSync(fixturePath);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('byte boundary does not misclassify 64K multibyte characters', () => {
|
||||
const byteBoundaryPrefix = 'é'.repeat(32768);
|
||||
const characterBoundaryPrefix = 'é'.repeat(65536);
|
||||
|
||||
assert.strictEqual(Buffer.byteLength(byteBoundaryPrefix, 'utf8'), 64 * 1024);
|
||||
assert.strictEqual(Buffer.byteLength(characterBoundaryPrefix, 'utf8'), 128 * 1024);
|
||||
assert.strictEqual(isRawPassthrough(`${byteBoundaryPrefix}tail`, byteBoundaryPrefix), true);
|
||||
assert.strictEqual(
|
||||
isRawPassthrough(`${characterBoundaryPrefix}tail`, characterBoundaryPrefix),
|
||||
false,
|
||||
'64K JavaScript characters must not be treated as a 64 KiB byte boundary'
|
||||
);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('64 KiB byte prefix split inside UTF-8 remains a raw passthrough', () => {
|
||||
const raw = Buffer.from(`${'a'.repeat(65535)}étail`, 'utf8');
|
||||
const cappedStdout = raw.subarray(0, 64 * 1024);
|
||||
|
||||
assert.strictEqual(cappedStdout.length, 64 * 1024);
|
||||
assert.strictEqual(cappedStdout.at(-1), Buffer.from('é', 'utf8')[0]);
|
||||
assert.strictEqual(
|
||||
isRawPassthrough(raw, cappedStdout),
|
||||
true,
|
||||
'classification must compare bytes before UTF-8 decoding can insert U+FFFD'
|
||||
);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('spawn classification suppresses a 64 KiB prefix split inside UTF-8', () => {
|
||||
const fixturePath = path.join(FIXTURE_DIR, 'split-byte-prefix-fixture.js');
|
||||
fs.writeFileSync(
|
||||
fixturePath,
|
||||
"const chunks=[]; process.stdin.on('data', chunk => chunks.push(chunk)); process.stdin.on('end', () => process.stdout.write(Buffer.concat(chunks).subarray(0, 64 * 1024)));"
|
||||
);
|
||||
try {
|
||||
const payload = `${'a'.repeat(65535)}étail`;
|
||||
const result = runBootstrap(['node', path.basename(fixturePath)], payload, {
|
||||
CLAUDE_PLUGIN_ROOT: FIXTURE_DIR
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '', 'classification must occur before UTF-8 decoding');
|
||||
assert.match(result.stderr, /returned raw input as stdout/);
|
||||
} finally {
|
||||
fs.unlinkSync(fixturePath);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
if (
|
||||
test('shell branch suppresses raw stdin echoed by the child', () => {
|
||||
const fixturePath = path.join(FIXTURE_DIR, 'passthrough-fixture.sh');
|
||||
fs.writeFileSync(fixturePath, 'cat\n');
|
||||
try {
|
||||
const payload = realisticPostToolUseEditPayload();
|
||||
const result = runBootstrap(['shell', path.basename(fixturePath)], payload, {
|
||||
CLAUDE_PLUGIN_ROOT: FIXTURE_DIR,
|
||||
BASH: fs.existsSync('/bin/sh') ? '/bin/sh' : 'sh'
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '', 'shell raw-input passthrough must be suppressed');
|
||||
assert.match(result.stderr, /returned raw input as stdout/);
|
||||
} finally {
|
||||
fs.unlinkSync(fixturePath);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
if (
|
||||
test('eval hook-entry preserves the original tool result when bootstrap stdout is empty', () => {
|
||||
const fixturePath = path.join(FIXTURE_DIR, 'entry-silent-fixture.js');
|
||||
fs.writeFileSync(fixturePath, "process.stdin.resume(); process.stdin.on('end', () => process.exit(0));");
|
||||
try {
|
||||
const payload = JSON.parse(realisticPostToolUseEditPayload());
|
||||
const originalToolResult = structuredClone(payload.tool_response);
|
||||
const result = runHookEntry(['node', path.basename(fixturePath)], JSON.stringify(payload), {
|
||||
CLAUDE_PLUGIN_ROOT: FIXTURE_DIR
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '', 'no-op hook entry must express no replacement result');
|
||||
|
||||
// Claude's hook-entry contract treats empty stdout as no hook override;
|
||||
// the tool result already present in the event remains authoritative.
|
||||
const effectiveToolResult = result.stdout === ''
|
||||
? payload.tool_response
|
||||
: JSON.parse(result.stdout).tool_response;
|
||||
assert.deepStrictEqual(effectiveToolResult, originalToolResult);
|
||||
} finally {
|
||||
fs.unlinkSync(fixturePath);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// --- Regression guard: hook with its OWN non-raw output (not equal to raw)
|
||||
// must still pass through unchanged. ---
|
||||
if (
|
||||
test('hook with its own non-raw output passes through unchanged', () => {
|
||||
const fixturePath = path.join(FIXTURE_DIR, 'own-output-fixture.js');
|
||||
const ownOutput = '{"hookSpecificOutput":{"additionalContext":"hello"}}\n';
|
||||
fs.writeFileSync(
|
||||
fixturePath,
|
||||
"process.stdin.resume(); process.stdin.on('end', () => { process.stdout.write('" + ownOutput.replace(/\n/g, '\\n').replace(/"/g, '\\"') + "'); process.exit(0); });"
|
||||
);
|
||||
try {
|
||||
const payload = realisticPostToolUseEditPayload();
|
||||
const result = runBootstrap(['node', path.basename(fixturePath)], payload, {
|
||||
CLAUDE_PLUGIN_ROOT: FIXTURE_DIR
|
||||
});
|
||||
assert.strictEqual(result.status, 0);
|
||||
// Should contain the hook's own output, not the raw input
|
||||
assert.ok(result.stdout.includes('additionalContext'), 'hook own output must be preserved');
|
||||
assert.ok(!result.stdout.includes('tool_response'), 'raw input must NOT be echoed when hook has its own output');
|
||||
} finally {
|
||||
fs.unlinkSync(fixturePath);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -11,7 +11,7 @@ const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'plugin-hook-bootstrap.js');
|
||||
const { normalizePluginRootForPlatform } = require(SCRIPT);
|
||||
const { normalizePluginRootForPlatform, withComparisonInput } = require(SCRIPT);
|
||||
|
||||
function createTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-hook-bootstrap-'));
|
||||
@@ -60,13 +60,26 @@ function runTests() {
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
if (test('passes stdin through when required bootstrap inputs are missing', () => {
|
||||
if (test('emits empty stdout and stderr warning when required bootstrap inputs are missing', () => {
|
||||
const result = run([], { input: '{"ok":true}' });
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, '{"ok":true}');
|
||||
assert.strictEqual(result.stderr, '');
|
||||
// Empty stdout (not the raw input) so the harness falls back to the
|
||||
// tool_use's original result -- prevents session-transcript bloat.
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.ok(result.stderr.includes('missing required args'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('wraps spawn results without mutating the original object', () => {
|
||||
const original = Object.freeze({ status: 0, stdout: 'ok', stderr: '' });
|
||||
const wrapped = withComparisonInput(original, 'raw-input');
|
||||
|
||||
assert.notStrictEqual(wrapped, original);
|
||||
assert.deepStrictEqual(original, { status: 0, stdout: 'ok', stderr: '' });
|
||||
assert.strictEqual(wrapped.comparisonInput, 'raw-input');
|
||||
assert.strictEqual(wrapped.stdout, 'ok');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('normalizes Windows Git Bash POSIX drive roots', () => {
|
||||
@@ -143,7 +156,7 @@ process.stdout.write(JSON.stringify({
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('node mode passes original stdin when child exits cleanly without stdout', () => {
|
||||
if (test('node mode emits empty stdout when child exits cleanly without stdout', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
writeFile(root, path.join('scripts', 'silent.js'), 'process.exit(0);\n');
|
||||
@@ -154,7 +167,10 @@ process.stdout.write(JSON.stringify({
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, 'raw-input');
|
||||
// Empty stdout (not the raw input) -- the dominant source of
|
||||
// session-transcript bloat pre-fix.
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.ok(result.stderr.includes('emitting empty stdout'));
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
@@ -225,7 +241,7 @@ process.exit(7);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shell mode fails open when no shell runtime is available', () => {
|
||||
if (test('shell mode fails open with empty stdout when no shell runtime is available', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
writeFile(root, path.join('scripts', 'hook.sh'), 'printf unreachable\n');
|
||||
@@ -237,14 +253,16 @@ process.exit(7);
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, 'raw-input');
|
||||
// Empty stdout (not the raw input) so the harness falls back to the
|
||||
// tool_use's original result.
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.ok(result.stderr.includes('shell runtime unavailable'));
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects target paths that escape the plugin root', () => {
|
||||
if (test('rejects target paths that escape the plugin root with empty stdout', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
const result = run(['node', path.join('..', 'outside.js')], {
|
||||
@@ -253,14 +271,16 @@ process.exit(7);
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, 'raw-input');
|
||||
// Empty stdout (not the raw input) -- the resolver throws, fallthrough
|
||||
// path emits empty + stderr explanation.
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.ok(result.stderr.includes('Path traversal rejected'));
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('unknown mode fails open with stderr warning', () => {
|
||||
if (test('unknown mode fails open with empty stdout and stderr warning', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
const result = run(['python', 'hook.py'], {
|
||||
@@ -269,7 +289,9 @@ process.exit(7);
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, 'raw-input');
|
||||
// Empty stdout (not the raw input) -- unknown mode fallthrough path
|
||||
// emits empty + stderr explanation.
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.ok(result.stderr.includes('unknown bootstrap mode: python'));
|
||||
} finally {
|
||||
cleanup(root);
|
||||
@@ -294,17 +316,17 @@ process.exit(7);
|
||||
|
||||
// Windows-only: PowerShell preference and .sh fallback behaviour.
|
||||
if (process.platform === 'win32') {
|
||||
if (test('shell mode selects PowerShell when BASH is unset on Windows', () => {
|
||||
// Skip if no PowerShell is available.
|
||||
const psProbe = spawnSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { stdio: 'ignore', timeout: 5000 });
|
||||
const ps = psProbe.error
|
||||
? spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { stdio: 'ignore', timeout: 5000 }).error
|
||||
? null : 'powershell.exe'
|
||||
: 'pwsh.exe';
|
||||
if (!ps) {
|
||||
console.log(' SKIP: no PowerShell found');
|
||||
return;
|
||||
}
|
||||
const psProbe = spawnSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { stdio: 'ignore', timeout: 5000 });
|
||||
const ps = psProbe.error
|
||||
? spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { stdio: 'ignore', timeout: 5000 }).error
|
||||
? null : 'powershell.exe'
|
||||
: 'pwsh.exe';
|
||||
|
||||
if (!ps) {
|
||||
skipped += 5;
|
||||
console.log(' SKIP 5 Windows shell-branch tests: PowerShell is unavailable');
|
||||
} else {
|
||||
if (test('shell mode selects PowerShell when BASH is unset on Windows', () => {
|
||||
|
||||
const root = createTempDir();
|
||||
try {
|
||||
@@ -327,15 +349,37 @@ process.exit(7);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shell mode falls back to bash for .sh scripts when PowerShell is the resolved shell', () => {
|
||||
// Skip if no bash is available (headless CI without Git for Windows).
|
||||
const bashProbe = spawnSync('bash.exe', ['-c', ':'], { stdio: 'ignore', timeout: 5000 });
|
||||
if (bashProbe.error) {
|
||||
console.log(' SKIP: bash.exe not found');
|
||||
return;
|
||||
if (test('PowerShell branch suppresses raw stdin echoed by the child', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
writeFile(root, path.join('scripts', 'passthrough.ps1'), [
|
||||
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
|
||||
'$OutputEncoding = [System.Text.Encoding]::UTF8',
|
||||
'$input_data = [Console]::In.ReadToEnd()',
|
||||
'[Console]::Out.Write($input_data)',
|
||||
].join('\n'));
|
||||
|
||||
const result = run(['shell', path.join('scripts', 'passthrough.ps1')], {
|
||||
root,
|
||||
input: 'raw-input',
|
||||
env: { BASH: '' },
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.ok(result.stderr.includes('returned raw input as stdout'));
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
const bashProbe = spawnSync('bash.exe', ['-c', ':'], { stdio: 'ignore', timeout: 5000 });
|
||||
const bashAvailable = !bashProbe.error && bashProbe.status === 0;
|
||||
|
||||
if (bashAvailable) {
|
||||
if (test('shell mode falls back to bash for .sh scripts when PowerShell is the resolved shell', () => {
|
||||
|
||||
const root = createTempDir();
|
||||
try {
|
||||
@@ -357,9 +401,32 @@ process.exit(7);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('shell mode emits skip warning for .sh script when no bash found on Windows', () => {
|
||||
if (test('PowerShell .sh fallback branch suppresses raw stdin echoed by bash', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
writeFile(root, path.join('scripts', 'passthrough.sh'), 'cat\n');
|
||||
|
||||
const result = run(['shell', path.join('scripts', 'passthrough.sh')], {
|
||||
root,
|
||||
input: 'raw-input',
|
||||
env: { BASH: '' },
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.ok(result.stderr.includes('returned raw input as stdout'));
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
} else {
|
||||
skipped += 2;
|
||||
console.log(' SKIP 2 Windows .sh fallback tests: bash.exe is unavailable');
|
||||
}
|
||||
|
||||
if (test('shell mode emits skip warning for .sh script when no bash found on Windows', () => {
|
||||
const root = createTempDir();
|
||||
try {
|
||||
writeFile(root, path.join('scripts', 'hook.sh'), 'printf unreachable\n');
|
||||
@@ -375,7 +442,7 @@ process.exit(7);
|
||||
});
|
||||
|
||||
assert.strictEqual(result.status, 0);
|
||||
assert.strictEqual(result.stdout, 'raw-input');
|
||||
assert.strictEqual(result.stdout, '');
|
||||
assert.ok(
|
||||
result.stderr.includes('no bash binary found') ||
|
||||
result.stderr.includes('shell runtime unavailable'),
|
||||
@@ -384,10 +451,14 @@ process.exit(7);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
if (skipped > 0) {
|
||||
console.log(`Skipped: ${skipped}`);
|
||||
}
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user