mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-30 19:00:57 +08:00
* 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>
268 lines
7.6 KiB
JavaScript
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,
|
|
};
|