From a224617abbfb9c4eeb20b13e6bfaa66f20d6a8db Mon Sep 17 00:00:00 2001 From: chs0813 <85471619@qq.com> Date: Sat, 4 Jul 2026 22:48:44 +0800 Subject: [PATCH 1/4] fix(hooks): do not echo raw input from plugin-hook-bootstrap.js Rebased onto origin/main (49128b576). Fixtures moved from scripts/hooks/ to /tmp/ecc-pr2380-fixtures/ per reviewer feedback. (Original commit b0e49036 was based on cc6724ee; main has since refactored spawnShell to use a shellArgs variable and added PowerShell .sh fallback paths. This rebase adapts the const result = spawnSync(...) + __rawInput tagging pattern to all three spawnSync call sites in spawnShell.) --- scripts/hooks/plugin-hook-bootstrap.js | 58 +++- .../plugin-hook-bootstrap-no-echo.test.js | 256 ++++++++++++++++++ tests/hooks/plugin-hook-bootstrap.test.js | 37 ++- 3 files changed, 326 insertions(+), 25 deletions(-) create mode 100644 tests/hooks/plugin-hook-bootstrap-no-echo.test.js diff --git a/scripts/hooks/plugin-hook-bootstrap.js b/scripts/hooks/plugin-hook-bootstrap.js index 00fce645a..c23ff0167 100644 --- a/scripts/hooks/plugin-hook-bootstrap.js +++ b/scripts/hooks/plugin-hook-bootstrap.js @@ -22,15 +22,40 @@ function writeStderr(stderr) { } } -function passthrough(raw, result) { +function passthrough(result) { const stdout = typeof result?.stdout === 'string' ? result.stdout : ''; if (stdout) { + // Most ECC hook scripts follow a `run(rawInput) -> rawInput` passthrough + // pattern: they do their work, then return the original input so the hook + // chain's tool result is preserved. The harness then writes the verbatim + // raw input (tool_input + tool_response, often 1-275 KB) into the session + // transcript as a hook_success attachment -- ~89% of every ECC session's + // transcript is this bloat. Detect the passthrough and emit empty stdout + // instead; the harness falls back to the tool_use's original result, the + // same path #2240 established for bash-hook-dispatcher. + // + // IMPORTANT: a strict `stdout === raw` check misses the common case where + // child processes' synchronous `process.stdout.write()` writes hit the + // ~64 KB Node.js pipe buffer and get truncated -- stdout is then exactly + // 65536 bytes and a strict prefix of raw. So we also detect that + // truncation sentinel. + const raw = typeof result?.__rawInput === 'string' ? result.__rawInput : ''; + const STDOUT_PIPE_CAP = 64 * 1024; + const looksLikePassthrough = + (stdout.length === STDOUT_PIPE_CAP && raw.startsWith(stdout)) || + (raw.length > 0 && stdout === raw); + if (looksLikePassthrough) { + writeStderr( + '[Hook] bootstrap: hook returned raw input as stdout; emitting empty to avoid transcript bloat\n' + ); + return; + } process.stdout.write(stdout); return; } if (!Number.isInteger(result?.status) || result.status === 0) { - process.stdout.write(raw); + writeStderr('[Hook] bootstrap: hook produced no output; emitting empty stdout\n'); } } @@ -146,7 +171,7 @@ function spawnNode(rootDir, relPath, raw, args) { CLAUDE_PLUGIN_ROOT: rootDir, ECC_PLUGIN_ROOT: rootDir, }; - return spawnSync(process.execPath, [resolveTarget(rootDir, relPath), ...args], { + const result = spawnSync(process.execPath, [resolveTarget(rootDir, relPath), ...args], { input: raw, encoding: 'utf8', env: hookEnv, @@ -154,6 +179,11 @@ function spawnNode(rootDir, relPath, raw, args) { timeout: 30000, windowsHide: true, }); + // Tag result with the raw input so passthrough() can detect the + // "hook returned raw input as stdout" pattern and suppress it + // (the dominant source of session-transcript bloat). + result.__rawInput = raw; + return result; } // spawnShell is not used by any hook in the shipped hooks.json configuration @@ -190,7 +220,7 @@ function spawnShell(rootDir, relPath, raw, args) { stderr: '[Hook] .sh script requested but no bash binary found on Windows; skipping\n', }; } - return spawnSync(bash, [scriptPath, ...args], { + const bashResult = spawnSync(bash, [scriptPath, ...args], { input: raw, encoding: 'utf8', env: hookEnv, @@ -198,6 +228,8 @@ function spawnShell(rootDir, relPath, raw, args) { timeout: 30000, windowsHide: true, }); + bashResult.__rawInput = raw; + return bashResult; } const shellArgs = isPs @@ -206,7 +238,7 @@ function spawnShell(rootDir, relPath, raw, args) { ? ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args] : [scriptPath, ...args]; - return spawnSync(shell, shellArgs, { + const result = spawnSync(shell, shellArgs, { input: raw, encoding: 'utf8', env: hookEnv, @@ -214,6 +246,8 @@ function spawnShell(rootDir, relPath, raw, args) { timeout: 30000, windowsHide: true, }); + result.__rawInput = raw; + return result; } function main() { @@ -224,7 +258,9 @@ function main() { ); if (!mode || !relPath || !rootDir) { - process.stdout.write(raw); + writeStderr( + '[Hook] bootstrap: missing required args (mode/relPath/rootDir); emitting empty stdout\n' + ); process.exit(0); } @@ -235,17 +271,15 @@ function main() { } else if (mode === 'shell') { result = spawnShell(rootDir, relPath, raw, args); } else { - writeStderr(`[Hook] unknown bootstrap mode: ${mode}\n`); - process.stdout.write(raw); + writeStderr(`[Hook] unknown bootstrap mode: ${mode}; emitting empty stdout\n`); process.exit(0); } } catch (error) { - writeStderr(`[Hook] bootstrap resolution failed: ${error.message}\n`); - process.stdout.write(raw); + writeStderr(`[Hook] bootstrap resolution failed: ${error.message}; emitting empty stdout\n`); process.exit(0); } - passthrough(raw, result); + passthrough(result); writeStderr(result.stderr); if (result.error || result.signal || result.status === null) { @@ -275,4 +309,4 @@ if (require.main === module || require.main === undefined) { module.exports = { main, normalizePluginRootForPlatform, -}; +}; \ No newline at end of file diff --git a/tests/hooks/plugin-hook-bootstrap-no-echo.test.js b/tests/hooks/plugin-hook-bootstrap-no-echo.test.js new file mode 100644 index 000000000..67662340b --- /dev/null +++ b/tests/hooks/plugin-hook-bootstrap-no-echo.test.js @@ -0,0 +1,256 @@ +/** + * 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 `/tmp/ecc-pr2380-fixtures/` (per reviewer feedback + * on #2380 — keep temp fixture files out of the live scripts/hooks/ tree). + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +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 FIXTURE_DIR = '/tmp/ecc-pr2380-fixtures'; + +function ensureFixtureDir() { + fs.mkdirSync(FIXTURE_DIR, { recursive: true }); +} + +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 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:'); + +ensureFixtureDir(); + +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. Lives in + // /tmp/ecc-pr2380-fixtures/ — 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', noopHookPath], payload, { + CLAUDE_PLUGIN_ROOT: repoRoot + }); + 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', fixturePath], payload, { + CLAUDE_PLUGIN_ROOT: repoRoot + }); + 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', fixturePath], payload, { + CLAUDE_PLUGIN_ROOT: repoRoot + }); + 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++; + +// --- 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', fixturePath], payload, { + CLAUDE_PLUGIN_ROOT: repoRoot + }); + 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('\n ' + passed + ' passed, ' + failed + ' failed\n'); +process.exit(failed > 0 ? 1 : 0); \ No newline at end of file diff --git a/tests/hooks/plugin-hook-bootstrap.test.js b/tests/hooks/plugin-hook-bootstrap.test.js index 694e44004..45011e1ce 100644 --- a/tests/hooks/plugin-hook-bootstrap.test.js +++ b/tests/hooks/plugin-hook-bootstrap.test.js @@ -61,12 +61,14 @@ function runTests() { let passed = 0; let failed = 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('normalizes Windows Git Bash POSIX drive roots', () => { @@ -143,7 +145,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 +156,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 +230,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 +242,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 +260,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 +278,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); @@ -375,7 +386,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'), @@ -391,4 +402,4 @@ process.exit(7); process.exit(failed > 0 ? 1 : 0); } -runTests(); +runTests(); \ No newline at end of file From 4c3ab4a6b729cce792268d74f8c7da11ee6cf46f Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 14 Aug 2026 23:11:53 +0800 Subject: [PATCH 2/4] test(hooks): close bootstrap review gaps --- scripts/hooks/plugin-hook-bootstrap.js | 36 +++-- .../plugin-hook-bootstrap-no-echo.test.js | 144 +++++++++++++++--- tests/hooks/plugin-hook-bootstrap.test.js | 71 +++++++-- 3 files changed, 205 insertions(+), 46 deletions(-) diff --git a/scripts/hooks/plugin-hook-bootstrap.js b/scripts/hooks/plugin-hook-bootstrap.js index c23ff0167..057b7365b 100644 --- a/scripts/hooks/plugin-hook-bootstrap.js +++ b/scripts/hooks/plugin-hook-bootstrap.js @@ -7,6 +7,7 @@ const { spawnSync } = require('child_process'); const { ensureAgentDataHomeEnv } = require('../lib/agent-data-home'); const SHELL_PROBE_TIMEOUT_MS = 2000; +const STDOUT_PIPE_CAP_BYTES = 64 * 1024; function readStdinRaw() { try { @@ -22,6 +23,18 @@ function writeStderr(stderr) { } } +function withComparisonInput(result, comparisonInput) { + return { ...result, comparisonInput }; +} + +function isRawPassthrough(raw, stdout) { + if (!raw || !stdout) return false; + return ( + stdout === raw || + (Buffer.byteLength(stdout, 'utf8') === STDOUT_PIPE_CAP_BYTES && raw.startsWith(stdout)) + ); +} + function passthrough(result) { const stdout = typeof result?.stdout === 'string' ? result.stdout : ''; if (stdout) { @@ -39,11 +52,8 @@ function passthrough(result) { // ~64 KB Node.js pipe buffer and get truncated -- stdout is then exactly // 65536 bytes and a strict prefix of raw. So we also detect that // truncation sentinel. - const raw = typeof result?.__rawInput === 'string' ? result.__rawInput : ''; - const STDOUT_PIPE_CAP = 64 * 1024; - const looksLikePassthrough = - (stdout.length === STDOUT_PIPE_CAP && raw.startsWith(stdout)) || - (raw.length > 0 && stdout === raw); + const raw = typeof result?.comparisonInput === 'string' ? result.comparisonInput : ''; + const looksLikePassthrough = isRawPassthrough(raw, stdout); if (looksLikePassthrough) { writeStderr( '[Hook] bootstrap: hook returned raw input as stdout; emitting empty to avoid transcript bloat\n' @@ -179,11 +189,7 @@ function spawnNode(rootDir, relPath, raw, args) { timeout: 30000, windowsHide: true, }); - // Tag result with the raw input so passthrough() can detect the - // "hook returned raw input as stdout" pattern and suppress it - // (the dominant source of session-transcript bloat). - result.__rawInput = raw; - return result; + return withComparisonInput(result, raw); } // spawnShell is not used by any hook in the shipped hooks.json configuration @@ -228,8 +234,7 @@ function spawnShell(rootDir, relPath, raw, args) { timeout: 30000, windowsHide: true, }); - bashResult.__rawInput = raw; - return bashResult; + return withComparisonInput(bashResult, raw); } const shellArgs = isPs @@ -246,8 +251,7 @@ function spawnShell(rootDir, relPath, raw, args) { timeout: 30000, windowsHide: true, }); - result.__rawInput = raw; - return result; + return withComparisonInput(result, raw); } function main() { @@ -307,6 +311,8 @@ if (require.main === module || require.main === undefined) { } module.exports = { + isRawPassthrough, main, normalizePluginRootForPlatform, -}; \ No newline at end of file + withComparisonInput, +}; diff --git a/tests/hooks/plugin-hook-bootstrap-no-echo.test.js b/tests/hooks/plugin-hook-bootstrap-no-echo.test.js index 67662340b..d72fa42aa 100644 --- a/tests/hooks/plugin-hook-bootstrap-no-echo.test.js +++ b/tests/hooks/plugin-hook-bootstrap-no-echo.test.js @@ -25,25 +25,30 @@ * - #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 `/tmp/ecc-pr2380-fixtures/` (per reviewer feedback - * on #2380 — keep temp fixture files out of the live scripts/hooks/ tree). + * 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 FIXTURE_DIR = '/tmp/ecc-pr2380-fixtures'; +const { isRawPassthrough } = require(bootstrap); +const FIXTURE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-pr2380-fixtures-')); -function ensureFixtureDir() { - fs.mkdirSync(FIXTURE_DIR, { recursive: true }); +function cleanupFixtureDir() { + fs.rmSync(FIXTURE_DIR, { recursive: true, force: true }); } +process.once('exit', cleanupFixtureDir); + function test(name, fn) { try { fn(); @@ -68,6 +73,19 @@ function runBootstrap(args, input, env) { }); } +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', @@ -88,8 +106,6 @@ function realisticPostToolUseEditPayload() { console.log('\nplugin-hook-bootstrap raw-echo (no bloat) tests:'); -ensureFixtureDir(); - let passed = 0; let failed = 0; @@ -129,13 +145,13 @@ else failed++; 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. Lives in - // /tmp/ecc-pr2380-fixtures/ — NOT in the live scripts/hooks/ tree. + // 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', noopHookPath], payload, { - CLAUDE_PLUGIN_ROOT: repoRoot + 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)'); @@ -181,8 +197,8 @@ if ( ); try { const payload = realisticPostToolUseEditPayload(); - const result = runBootstrap(['node', fixturePath], payload, { - CLAUDE_PLUGIN_ROOT: repoRoot + 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'); @@ -211,8 +227,8 @@ if ( ); try { const payload = realisticPostToolUseEditPayload(); - const result = runBootstrap(['node', fixturePath], payload, { - CLAUDE_PLUGIN_ROOT: repoRoot + 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)'); @@ -225,6 +241,98 @@ if ( 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 (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 ( @@ -237,8 +345,8 @@ if ( ); try { const payload = realisticPostToolUseEditPayload(); - const result = runBootstrap(['node', fixturePath], payload, { - CLAUDE_PLUGIN_ROOT: repoRoot + 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 @@ -253,4 +361,4 @@ if ( else failed++; console.log('\n ' + passed + ' passed, ' + failed + ' failed\n'); -process.exit(failed > 0 ? 1 : 0); \ No newline at end of file +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/hooks/plugin-hook-bootstrap.test.js b/tests/hooks/plugin-hook-bootstrap.test.js index 45011e1ce..bddf5e958 100644 --- a/tests/hooks/plugin-hook-bootstrap.test.js +++ b/tests/hooks/plugin-hook-bootstrap.test.js @@ -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-')); @@ -71,6 +71,16 @@ function runTests() { 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', () => { assert.strictEqual( normalizePluginRootForPlatform('/c/Users/x/.claude/plugins/ecc', 'win32'), @@ -306,16 +316,12 @@ 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; - } + assert.ok(ps, 'Windows shell-path coverage requires PowerShell'); const root = createTempDir(); try { @@ -340,13 +346,33 @@ process.exit(7); } })) 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++; + + if (test('shell mode falls back to bash for .sh scripts when PowerShell is the resolved shell', () => { + const bashProbe = spawnSync('bash.exe', ['-c', ':'], { stdio: 'ignore', timeout: 5000 }); + assert.ok(!bashProbe.error && bashProbe.status === 0, 'Windows .sh fallback coverage requires bash.exe'); const root = createTempDir(); try { @@ -370,6 +396,25 @@ process.exit(7); } })) passed++; else failed++; + 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++; + if (test('shell mode emits skip warning for .sh script when no bash found on Windows', () => { const root = createTempDir(); try { @@ -402,4 +447,4 @@ process.exit(7); process.exit(failed > 0 ? 1 : 0); } -runTests(); \ No newline at end of file +runTests(); From 74afefb553706d03da072d90e25b4d4c43929cde Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 14 Aug 2026 23:39:13 +0800 Subject: [PATCH 3/4] fix(hooks): compare passthrough output as bytes --- scripts/hooks/plugin-hook-bootstrap.js | 48 ++++++++++++------- .../plugin-hook-bootstrap-no-echo.test.js | 42 +++++++++++++++- 2 files changed, 71 insertions(+), 19 deletions(-) diff --git a/scripts/hooks/plugin-hook-bootstrap.js b/scripts/hooks/plugin-hook-bootstrap.js index 057b7365b..627afeeca 100644 --- a/scripts/hooks/plugin-hook-bootstrap.js +++ b/scripts/hooks/plugin-hook-bootstrap.js @@ -18,26 +18,37 @@ function readStdinRaw() { } function writeStderr(stderr) { - if (typeof stderr === 'string' && stderr.length > 0) { + if ((typeof stderr === 'string' || Buffer.isBuffer(stderr)) && stderr.length > 0) { process.stderr.write(stderr); } } +function toBuffer(value) { + if (Buffer.isBuffer(value)) return value; + return typeof value === 'string' ? Buffer.from(value, 'utf8') : Buffer.alloc(0); +} + function withComparisonInput(result, comparisonInput) { return { ...result, comparisonInput }; } function isRawPassthrough(raw, stdout) { - if (!raw || !stdout) return false; + const rawBytes = toBuffer(raw); + const stdoutBytes = toBuffer(stdout); + if (rawBytes.length === 0 || stdoutBytes.length === 0) return false; return ( - stdout === raw || - (Buffer.byteLength(stdout, 'utf8') === STDOUT_PIPE_CAP_BYTES && raw.startsWith(stdout)) + stdoutBytes.equals(rawBytes) || + (stdoutBytes.length === STDOUT_PIPE_CAP_BYTES && + rawBytes.subarray(0, stdoutBytes.length).equals(stdoutBytes)) ); } function passthrough(result) { - const stdout = typeof result?.stdout === 'string' ? result.stdout : ''; - if (stdout) { + const stdout = + typeof result?.stdout === 'string' || Buffer.isBuffer(result?.stdout) + ? result.stdout + : Buffer.alloc(0); + if (stdout.length > 0) { // Most ECC hook scripts follow a `run(rawInput) -> rawInput` passthrough // pattern: they do their work, then return the original input so the hook // chain's tool result is preserved. The harness then writes the verbatim @@ -52,7 +63,7 @@ function passthrough(result) { // ~64 KB Node.js pipe buffer and get truncated -- stdout is then exactly // 65536 bytes and a strict prefix of raw. So we also detect that // truncation sentinel. - const raw = typeof result?.comparisonInput === 'string' ? result.comparisonInput : ''; + const raw = result?.comparisonInput; const looksLikePassthrough = isRawPassthrough(raw, stdout); if (looksLikePassthrough) { writeStderr( @@ -183,13 +194,12 @@ function spawnNode(rootDir, relPath, raw, args) { }; const result = spawnSync(process.execPath, [resolveTarget(rootDir, relPath), ...args], { input: raw, - encoding: 'utf8', env: hookEnv, cwd: process.cwd(), timeout: 30000, windowsHide: true, }); - return withComparisonInput(result, raw); + return withComparisonInput(result, Buffer.from(raw, 'utf8')); } // spawnShell is not used by any hook in the shipped hooks.json configuration @@ -228,13 +238,12 @@ function spawnShell(rootDir, relPath, raw, args) { } const bashResult = spawnSync(bash, [scriptPath, ...args], { input: raw, - encoding: 'utf8', env: hookEnv, cwd: process.cwd(), timeout: 30000, windowsHide: true, }); - return withComparisonInput(bashResult, raw); + return withComparisonInput(bashResult, Buffer.from(raw, 'utf8')); } const shellArgs = isPs @@ -245,13 +254,12 @@ function spawnShell(rootDir, relPath, raw, args) { const result = spawnSync(shell, shellArgs, { input: raw, - encoding: 'utf8', env: hookEnv, cwd: process.cwd(), timeout: 30000, windowsHide: true, }); - return withComparisonInput(result, raw); + return withComparisonInput(result, Buffer.from(raw, 'utf8')); } function main() { @@ -265,7 +273,8 @@ function main() { writeStderr( '[Hook] bootstrap: missing required args (mode/relPath/rootDir); emitting empty stdout\n' ); - process.exit(0); + process.exitCode = 0; + return; } let result; @@ -276,11 +285,13 @@ function main() { result = spawnShell(rootDir, relPath, raw, args); } else { writeStderr(`[Hook] unknown bootstrap mode: ${mode}; emitting empty stdout\n`); - process.exit(0); + process.exitCode = 0; + return; } } catch (error) { writeStderr(`[Hook] bootstrap resolution failed: ${error.message}; emitting empty stdout\n`); - process.exit(0); + process.exitCode = 0; + return; } passthrough(result); @@ -293,10 +304,11 @@ function main() { ? `terminated by signal ${result.signal}` : 'missing exit status'; writeStderr(`[Hook] bootstrap execution failed: ${reason}\n`); - process.exit(0); + process.exitCode = 0; + return; } - process.exit(Number.isInteger(result.status) ? result.status : 0); + process.exitCode = Number.isInteger(result.status) ? result.status : 0; } // Run when invoked as a hook entry. Production hooks load this via diff --git a/tests/hooks/plugin-hook-bootstrap-no-echo.test.js b/tests/hooks/plugin-hook-bootstrap-no-echo.test.js index d72fa42aa..ed94df763 100644 --- a/tests/hooks/plugin-hook-bootstrap-no-echo.test.js +++ b/tests/hooks/plugin-hook-bootstrap-no-echo.test.js @@ -283,6 +283,46 @@ if ( 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', () => { @@ -360,5 +400,5 @@ if ( passed++; else failed++; -console.log('\n ' + passed + ' passed, ' + failed + ' failed\n'); +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); From e568d696c5b78b46f3110f378829832b8a0823b2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 14 Aug 2026 23:40:56 +0800 Subject: [PATCH 4/4] test(hooks): report unavailable Windows runtimes --- tests/hooks/plugin-hook-bootstrap.test.js | 49 +++++++++++++++-------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/tests/hooks/plugin-hook-bootstrap.test.js b/tests/hooks/plugin-hook-bootstrap.test.js index bddf5e958..f00c56149 100644 --- a/tests/hooks/plugin-hook-bootstrap.test.js +++ b/tests/hooks/plugin-hook-bootstrap.test.js @@ -60,6 +60,7 @@ function runTests() { let passed = 0; let failed = 0; + let skipped = 0; if (test('emits empty stdout and stderr warning when required bootstrap inputs are missing', () => { const result = run([], { input: '{"ok":true}' }); @@ -315,13 +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', () => { - 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'; - assert.ok(ps, 'Windows shell-path coverage requires PowerShell'); + 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 { @@ -344,9 +349,9 @@ process.exit(7); } finally { cleanup(root); } - })) passed++; else failed++; + })) passed++; else failed++; - if (test('PowerShell branch suppresses raw stdin echoed by the child', () => { + if (test('PowerShell branch suppresses raw stdin echoed by the child', () => { const root = createTempDir(); try { writeFile(root, path.join('scripts', 'passthrough.ps1'), [ @@ -368,11 +373,13 @@ 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', () => { const bashProbe = spawnSync('bash.exe', ['-c', ':'], { stdio: 'ignore', timeout: 5000 }); - assert.ok(!bashProbe.error && bashProbe.status === 0, 'Windows .sh fallback coverage requires bash.exe'); + 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 { @@ -394,9 +401,9 @@ process.exit(7); } finally { cleanup(root); } - })) passed++; else failed++; + })) passed++; else failed++; - if (test('PowerShell .sh fallback branch suppresses raw stdin echoed by bash', () => { + 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'); @@ -413,9 +420,13 @@ process.exit(7); } finally { cleanup(root); } - })) passed++; else failed++; + })) 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', () => { + 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'); @@ -440,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); }