forked from affaan-m/ECC
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplugin-hook-bootstrap.js
More file actions
278 lines (241 loc) · 8.08 KB
/
Copy pathplugin-hook-bootstrap.js
File metadata and controls
278 lines (241 loc) · 8.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
#!/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');
const SHELL_PROBE_TIMEOUT_MS = 2000;
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: SHELL_PROBE_TIMEOUT_MS,
});
// A candidate is only usable if it both spawns AND exits cleanly. The
// Windows System32 bash.exe WSL launcher spawns without error but exits
// non-zero when no distro is installed, so !probe.error alone is not enough.
if (!probe.error && probe.status === 0) {
_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: SHELL_PROBE_TIMEOUT_MS,
});
// Require a clean exit, not just a successful spawn: the Windows System32
// bash.exe WSL stub spawns fine but exits non-zero with no distro installed.
if (!probe.error && probe.status === 0) {
_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,
};