diff --git a/scripts/hooks/plugin-hook-bootstrap.js b/scripts/hooks/plugin-hook-bootstrap.js index cc6724ee..7a787ec2 100644 --- a/scripts/hooks/plugin-hook-bootstrap.js +++ b/scripts/hooks/plugin-hook-bootstrap.js @@ -58,28 +58,73 @@ function resolveTarget(rootDir, 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') { - candidates.push('bash.exe', 'bash'); + // 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, ['-c', ':'], { + const probe = spawnSync(candidate, isPowerShellBin(candidate) ? psProbeArgs : shProbeArgs, { stdio: 'ignore', windowsHide: true, + timeout: 30000, }); if (!probe.error) { - return candidate; + _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; } @@ -100,6 +145,10 @@ function spawnNode(rootDir, relPath, raw, args) { }); } +// 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) { @@ -116,7 +165,37 @@ function spawnShell(rootDir, relPath, raw, args) { CLAUDE_PLUGIN_ROOT: rootDir, ECC_PLUGIN_ROOT: rootDir, }; - return spawnSync(shell, [resolveTarget(rootDir, relPath), ...args], { + 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, diff --git a/tests/hooks/plugin-hook-bootstrap.test.js b/tests/hooks/plugin-hook-bootstrap.test.js index b5a913d3..694e4400 100644 --- a/tests/hooks/plugin-hook-bootstrap.test.js +++ b/tests/hooks/plugin-hook-bootstrap.test.js @@ -292,6 +292,101 @@ process.exit(7); } })) passed++; else failed++; + // 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 root = createTempDir(); + try { + // UTF8 encoding set explicitly — PowerShell 5.1 defaults to UTF-16LE. + writeFile(root, path.join('scripts', 'hook.ps1'), [ + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + '$OutputEncoding = [System.Text.Encoding]::UTF8', + '$input_data = [Console]::In.ReadToEnd()', + 'Write-Host -NoNewline ("ps1:" + $args[0] + ":" + $input_data)', + ].join('\n')); + + const result = run(['shell', path.join('scripts', 'hook.ps1'), 'arg'], { + root, + input: 'payload', + env: { BASH: '' }, + }); + + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, 'ps1:arg:payload'); + } finally { + cleanup(root); + } + })) 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; + } + + const root = createTempDir(); + try { + writeFile(root, path.join('scripts', 'hook.sh'), [ + 'input=$(cat)', + 'printf "sh:%s:%s" "$1" "$input"', + '', + ].join('\n')); + + // Clear BASH so PowerShell is resolved first, but script is .sh. + const result = run(['shell', path.join('scripts', 'hook.sh'), 'arg'], { + root, + input: 'payload', + env: { BASH: '' }, + }); + + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, 'sh:arg:payload'); + } 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 { + writeFile(root, path.join('scripts', 'hook.sh'), 'printf unreachable\n'); + + // Keep PowerShell on PATH so it is resolved as the shell, then strip + // bash candidates so the .sh fallback path hits the skip-warning branch. + const result = run(['shell', path.join('scripts', 'hook.sh')], { + root, + input: 'raw-input', + env: { BASH: '', PATH: process.env.SystemRoot + ? `${process.env.SystemRoot}\\System32\\WindowsPowerShell\\v1.0;${process.env.SystemRoot}\\System32` + : '' }, + }); + + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, 'raw-input'); + assert.ok( + result.stderr.includes('no bash binary found') || + result.stderr.includes('shell runtime unavailable'), + `unexpected stderr: ${result.stderr}` + ); + } finally { + cleanup(root); + } + })) passed++; else failed++; + } + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); }