fix(hooks): compare passthrough output as bytes

This commit is contained in:
Your Name
2026-08-14 23:39:13 +08:00
parent 4c3ab4a6b7
commit 74afefb553
2 changed files with 71 additions and 19 deletions
+30 -18
View File
@@ -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
@@ -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);