everything-claude-code/scripts/hooks/plugin-hook-bootstrap.js
ChrisD 64797fd895
fix(windows): prefer PowerShell over bash to prevent zombie process accumulation (#2346)
* fix(windows): prefer PowerShell over bash to prevent zombie process accumulation

On Windows, ECC hook scripts were spawning bash.exe (MSYS2/Git Bash) on
every tool use via findShellBinary(). These processes were not reaped by
Windows, causing 40+ zombie bash.exe/conhost.exe processes per session with
noticeable system lag.

Changes to scripts/hooks/plugin-hook-bootstrap.js:
- Add isPowerShellBin(bin) helper: basename-based detection so full paths
  like C:\Windows\...\powershell.exe are handled correctly
- findShellBinary(): check BASH env var first (preserves escape hatch),
  then on win32 probe pwsh.exe -> powershell.exe -> bash.exe -> bash;
  use correct probe args per shell type; cache result in _cachedShell
- findBashBinary(): separate cached bash-only finder used by spawnShell
  .sh fallback; skips PowerShell binaries even if BASH points to one
- spawnShell(): use isPowerShellBin() to select -NoProfile -NonInteractive
  -File args for PowerShell; .sh scripts fall back to findBashBinary()
  with a skip-warning if no bash found on Windows

observe-runner.js is intentionally unchanged: it always invokes observe.sh
which is bash-only; routing it through PowerShell would silently break it.
The observe.sh -> observe.js migration is tracked separately.

Fixes #2345

* fix(windows): address CodeRabbit and Greptile review comments

- Add timeout: 30000 to all spawnSync probe calls in findShellBinary and
  findBashBinary to prevent hangs on broken/stalled shell candidates
- Add -ExecutionPolicy Bypass to PowerShell -File invocation to fix
  execution on machines with the default Restricted policy (Win10/11)
- Add PowerShell availability skip guard to PS selection test (mirrors
  existing bash skip guard)
- Fix no-bash test to keep PowerShell on PATH so the .sh fallback branch
  is actually exercised rather than hitting shell-unavailable early exit

* test: add timeout to spawnSync probes in Windows test skip guards

---------

Co-authored-by: Christopher J Diamond <diamondcj@leidos.com>
2026-06-29 15:54:58 -07:00

268 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { ensureAgentDataHomeEnv } = require('../lib/agent-data-home');
function readStdinRaw() {
try {
return fs.readFileSync(0, 'utf8');
} catch (_error) {
return '';
}
}
function writeStderr(stderr) {
if (typeof stderr === 'string' && stderr.length > 0) {
process.stderr.write(stderr);
}
}
function passthrough(raw, result) {
const stdout = typeof result?.stdout === 'string' ? result.stdout : '';
if (stdout) {
process.stdout.write(stdout);
return;
}
if (!Number.isInteger(result?.status) || result.status === 0) {
process.stdout.write(raw);
}
}
function normalizePluginRootForPlatform(rootDir, platform = process.platform) {
if (platform !== 'win32' || typeof rootDir !== 'string') {
return rootDir;
}
const match = rootDir.match(/^\/([a-zA-Z])(?:\/(.*))?$/);
if (!match) {
return rootDir;
}
const [, driveLetter, rest = ''] = match;
return `${driveLetter.toUpperCase()}:/${rest}`;
}
function resolveTarget(rootDir, relPath) {
const resolvedRoot = path.resolve(rootDir);
const resolvedTarget = path.resolve(rootDir, relPath);
if (
resolvedTarget !== resolvedRoot &&
!resolvedTarget.startsWith(resolvedRoot + path.sep)
) {
throw new Error(`Path traversal rejected: ${relPath}`);
}
return resolvedTarget;
}
let _cachedShell = undefined;
let _cachedBash = undefined;
function isPowerShellBin(bin) {
const base = path.basename(bin).toLowerCase();
return base === 'pwsh.exe' || base === 'pwsh' || base === 'powershell.exe' || base === 'powershell';
}
function findShellBinary() {
if (_cachedShell !== undefined) return _cachedShell;
const candidates = [];
// Explicit override always wins — check before any platform probing.
// Warning: setting BASH to a bash binary on Windows bypasses the PowerShell
// preference and may reintroduce bash.exe zombie accumulation.
if (process.env.BASH && process.env.BASH.trim()) {
candidates.push(process.env.BASH.trim());
}
if (process.platform === 'win32') {
// Prefer PowerShell on Windows — it is native and does not leave zombie
// bash.exe / conhost.exe processes the way MSYS2/Git Bash does.
// Note: PowerShell is only suitable for .ps1 scripts; callers that need
// to run .sh scripts (e.g. observe-runner.js) must not use this function.
candidates.push('pwsh.exe', 'powershell.exe', 'bash.exe', 'bash');
} else {
candidates.push('bash', 'sh');
}
const psProbeArgs = ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'];
const shProbeArgs = ['-c', ':'];
for (const candidate of candidates) {
const probe = spawnSync(candidate, isPowerShellBin(candidate) ? psProbeArgs : shProbeArgs, {
stdio: 'ignore',
windowsHide: true,
timeout: 30000,
});
if (!probe.error) {
_cachedShell = candidate;
return _cachedShell;
}
}
_cachedShell = null;
return null;
}
function findBashBinary() {
if (_cachedBash !== undefined) return _cachedBash;
const candidates = [];
if (process.env.BASH && process.env.BASH.trim() && !isPowerShellBin(process.env.BASH.trim())) {
candidates.push(process.env.BASH.trim());
}
candidates.push('bash.exe', 'bash');
for (const candidate of candidates) {
const probe = spawnSync(candidate, ['-c', ':'], { stdio: 'ignore', windowsHide: true, timeout: 30000 });
if (!probe.error) {
_cachedBash = candidate;
return _cachedBash;
}
}
_cachedBash = null;
return null;
}
function spawnNode(rootDir, relPath, raw, args) {
ensureAgentDataHomeEnv();
const hookEnv = {
...process.env,
CLAUDE_PLUGIN_ROOT: rootDir,
ECC_PLUGIN_ROOT: rootDir,
};
return spawnSync(process.execPath, [resolveTarget(rootDir, relPath), ...args], {
input: raw,
encoding: 'utf8',
env: hookEnv,
cwd: process.cwd(),
timeout: 30000,
windowsHide: true,
});
}
// spawnShell is not used by any hook in the shipped hooks.json configuration
// (all hooks use 'node' mode). It is provided for third-party plugins that
// register shell-backed hooks. Plugins should supply .ps1 scripts on Windows
// and .sh scripts on Unix; mixing them will produce a skip with a stderr warning.
function spawnShell(rootDir, relPath, raw, args) {
const shell = findShellBinary();
if (!shell) {
return {
status: 0,
stdout: '',
stderr: '[Hook] shell runtime unavailable; skipping shell-backed hook\n',
};
}
ensureAgentDataHomeEnv();
const hookEnv = {
...process.env,
CLAUDE_PLUGIN_ROOT: rootDir,
ECC_PLUGIN_ROOT: rootDir,
};
const scriptPath = resolveTarget(rootDir, relPath);
const isPs = isPowerShellBin(shell);
// PowerShell cannot interpret bash scripts — fall back to a bash candidate
// rather than silently failing the hook.
if (isPs && scriptPath.endsWith('.sh')) {
const bash = findBashBinary();
if (!bash) {
return {
status: 0,
stdout: '',
stderr: '[Hook] .sh script requested but no bash binary found on Windows; skipping\n',
};
}
return spawnSync(bash, [scriptPath, ...args], {
input: raw,
encoding: 'utf8',
env: hookEnv,
cwd: process.cwd(),
timeout: 30000,
windowsHide: true,
});
}
const shellArgs = isPs
// -ExecutionPolicy Bypass: default Windows policy (Restricted) blocks -File
// execution of .ps1 scripts; Bypass scopes only to this child process.
? ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
: [scriptPath, ...args];
return spawnSync(shell, shellArgs, {
input: raw,
encoding: 'utf8',
env: hookEnv,
cwd: process.cwd(),
timeout: 30000,
windowsHide: true,
});
}
function main() {
const [, , mode, relPath, ...args] = process.argv;
const raw = readStdinRaw();
const rootDir = normalizePluginRootForPlatform(
process.env.CLAUDE_PLUGIN_ROOT || process.env.ECC_PLUGIN_ROOT
);
if (!mode || !relPath || !rootDir) {
process.stdout.write(raw);
process.exit(0);
}
let result;
try {
if (mode === 'node') {
result = spawnNode(rootDir, relPath, raw, args);
} else if (mode === 'shell') {
result = spawnShell(rootDir, relPath, raw, args);
} else {
writeStderr(`[Hook] unknown bootstrap mode: ${mode}\n`);
process.stdout.write(raw);
process.exit(0);
}
} catch (error) {
writeStderr(`[Hook] bootstrap resolution failed: ${error.message}\n`);
process.stdout.write(raw);
process.exit(0);
}
passthrough(raw, result);
writeStderr(result.stderr);
if (result.error || result.signal || result.status === null) {
const reason = result.error
? result.error.message
: result.signal
? `terminated by signal ${result.signal}`
: 'missing exit status';
writeStderr(`[Hook] bootstrap execution failed: ${reason}\n`);
process.exit(0);
}
process.exit(Number.isInteger(result.status) ? result.status : 0);
}
// Run when invoked as a hook entry. Production hooks load this via
// `node -e "...; process.argv.splice(1,0,s); require(s)"`; on Node 21+ that
// leaves require.main undefined (not this module), which previously skipped
// main() and made every plugin hook a silent no-op. Guard on both the
// direct-entry case and that eval-bootstrap case. When imported for its
// exports (tests), require.main is a real, different module, so main() stays
// dormant.
if (require.main === module || require.main === undefined) {
main();
}
module.exports = {
main,
normalizePluginRootForPlatform,
};