mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 20:25:26 +02:00
plugin-hook-bootstrap (shell mode) and observe-runner probed a bare
`bash.exe`. On Windows that resolves to the first match on PATH, and
%SystemRoot%\System32 normally sits ahead of Git's bin directory, so the
pick was System32\bash.exe: the WSL launcher. The existing guard (require
a clean probe exit) only catches it when no distro is installed. With one
installed, the probe passes inside Linux, then the hook script is handed
over by a Windows path (C:\...) or a Git Bash path (/c/...), neither of
which exists in WSL. The hook exits 127 instead of running, and
observe-runner's continuous-learning observation fails on every call.
Resolve bash candidates on Windows to absolute paths from PATH, skipping
bash.exe in System32, SysWOW64, Sysnative, and the WindowsApps alias
directory, so a native bash later on PATH (Git's) is used. With none
available, the existing "no bash binary found" skip applies. An explicit
BASH override still wins, and other platforms keep bare names.
The shared resolver lives in scripts/lib/windows-bash.js with a
platform-independent test. A Windows integration test puts System32 ahead
of Git's bash and requires the .sh hook to actually run. Upstream's
bootstrap fails that scenario with exit 127 ("/bin/bash: C:Users...hook.sh:
No such file or directory"); this one prints the hook output.
203 lines
5.7 KiB
JavaScript
203 lines
5.7 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
const { resolveWindowsBashCandidates } = require('../lib/windows-bash');
|
|
|
|
const OBSERVE_RELATIVE_PATH = path.join('skills', 'continuous-learning-v2', 'hooks', 'observe.sh');
|
|
const DEFAULT_TIMEOUT_MS = 9000;
|
|
|
|
function getPluginRoot(options = {}) {
|
|
if (options.pluginRoot && String(options.pluginRoot).trim()) {
|
|
return String(options.pluginRoot).trim();
|
|
}
|
|
if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) {
|
|
return process.env.CLAUDE_PLUGIN_ROOT.trim();
|
|
}
|
|
if (process.env.ECC_PLUGIN_ROOT && process.env.ECC_PLUGIN_ROOT.trim()) {
|
|
return process.env.ECC_PLUGIN_ROOT.trim();
|
|
}
|
|
return path.resolve(__dirname, '..', '..');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function toShellPath(filePath) {
|
|
const normalized = String(filePath || '');
|
|
if (process.platform !== 'win32') {
|
|
return normalized;
|
|
}
|
|
|
|
return normalized
|
|
.replace(/^([A-Za-z]):[\\/]/, (_, driveLetter) => `/${driveLetter.toLowerCase()}/`)
|
|
.replace(/\\/g, '/');
|
|
}
|
|
|
|
function findShellBinary() {
|
|
const candidates = [];
|
|
if (process.env.BASH && process.env.BASH.trim()) {
|
|
candidates.push(process.env.BASH.trim());
|
|
}
|
|
|
|
if (process.platform === 'win32') {
|
|
// Absolute paths, skipping WSL launchers: WSL cannot open the /c/... path
|
|
// toShellPath() hands the shell, so observe.sh would fail on every call.
|
|
candidates.push(...resolveWindowsBashCandidates(['bash.exe', 'bash', 'sh']));
|
|
} else {
|
|
candidates.push('bash', 'sh');
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
const probe = spawnSync(candidate, ['-c', ':'], {
|
|
stdio: 'ignore',
|
|
windowsHide: true
|
|
});
|
|
// Require the probe to actually succeed, not just spawn: Windows'
|
|
// System32\bash.exe (WSL launcher) spawns even with no distro installed but
|
|
// exits non-zero, so `!probe.error` alone would treat it as a usable shell.
|
|
if (!probe.error && probe.status === 0) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getPhaseFromHookId(hookId) {
|
|
const prefix = String(hookId || process.env.ECC_HOOK_ID || '').split(':')[0];
|
|
return prefix === 'pre' || prefix === 'post' ? prefix : null;
|
|
}
|
|
|
|
function getTimeoutMs() {
|
|
const parsed = Number.parseInt(process.env.ECC_OBSERVE_RUNNER_TIMEOUT_MS || '', 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
|
|
}
|
|
|
|
function combineStderr(stderr, message) {
|
|
const prefix = typeof stderr === 'string' && stderr.length > 0
|
|
? stderr.endsWith('\n') ? stderr : `${stderr}\n`
|
|
: '';
|
|
return `${prefix}${message}\n`;
|
|
}
|
|
|
|
function run(raw, options = {}) {
|
|
const input = typeof raw === 'string' ? raw : String(raw ?? '');
|
|
const phase = getPhaseFromHookId(options.hookId);
|
|
if (!phase) {
|
|
return {
|
|
stderr: '[Hook] observe runner received an unsupported hook id; skipping observation',
|
|
exitCode: 0
|
|
};
|
|
}
|
|
|
|
const pluginRoot = getPluginRoot(options);
|
|
let observePath;
|
|
try {
|
|
observePath = resolveTarget(pluginRoot, OBSERVE_RELATIVE_PATH);
|
|
} catch (error) {
|
|
return {
|
|
stderr: `[Hook] observe runner path resolution failed: ${error.message}`,
|
|
exitCode: 0
|
|
};
|
|
}
|
|
|
|
if (!fs.existsSync(observePath)) {
|
|
return {
|
|
stderr: `[Hook] observe script not found: ${observePath}`,
|
|
exitCode: 0
|
|
};
|
|
}
|
|
|
|
const shell = findShellBinary();
|
|
if (!shell) {
|
|
return {
|
|
stderr: '[Hook] shell runtime unavailable; skipping continuous-learning observation',
|
|
exitCode: 0
|
|
};
|
|
}
|
|
|
|
const result = spawnSync(shell, [toShellPath(observePath), phase], {
|
|
input,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
CLAUDE_PLUGIN_ROOT: pluginRoot,
|
|
ECC_PLUGIN_ROOT: pluginRoot
|
|
},
|
|
cwd: process.cwd(),
|
|
timeout: getTimeoutMs(),
|
|
windowsHide: true
|
|
});
|
|
|
|
const output = {
|
|
exitCode: Number.isInteger(result.status) ? result.status : 0
|
|
};
|
|
|
|
if (typeof result.stdout === 'string' && result.stdout.length > 0) {
|
|
output.stdout = result.stdout;
|
|
}
|
|
if (typeof result.stderr === 'string' && result.stderr.length > 0) {
|
|
output.stderr = 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';
|
|
output.stderr = combineStderr(output.stderr, `[Hook] observe runner failed: ${reason}`);
|
|
output.exitCode = 0;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function emitHookResult(raw, output) {
|
|
if (output && typeof output === 'object') {
|
|
if (output.stderr) {
|
|
process.stderr.write(String(output.stderr).endsWith('\n') ? String(output.stderr) : `${output.stderr}\n`);
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(output, 'stdout')) {
|
|
process.stdout.write(String(output.stdout ?? ''));
|
|
} else if (!Number.isInteger(output.exitCode) || output.exitCode === 0) {
|
|
process.stdout.write(raw);
|
|
}
|
|
return Number.isInteger(output.exitCode) ? output.exitCode : 0;
|
|
}
|
|
|
|
process.stdout.write(raw);
|
|
return 0;
|
|
}
|
|
|
|
if (require.main === module) {
|
|
let raw = '';
|
|
try {
|
|
raw = fs.readFileSync(0, 'utf8');
|
|
} catch (_error) {
|
|
raw = '';
|
|
}
|
|
const output = run(raw, { hookId: process.argv[2] || process.env.ECC_HOOK_ID });
|
|
process.exit(emitHookResult(raw, output));
|
|
}
|
|
|
|
module.exports = {
|
|
OBSERVE_RELATIVE_PATH,
|
|
findShellBinary,
|
|
getPhaseFromHookId,
|
|
run,
|
|
toShellPath
|
|
};
|