@ngockhoale/ukit 3.4.20 → 3.4.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/manifests/documentation.yaml +11 -0
- package/manifests/platform.full.yaml +13 -0
- package/package.json +1 -1
- package/src/core/hookChainDoctor.js +39 -3
- package/src/core/output/index.js +13 -3
- package/template_project/.claude/.gitattributes +2 -0
- package/template_project/.claude/ukit/index/route-task.mjs +11 -0
- package/template_project/.claude/ukit/index/unic-decision.mjs +122 -3
- package/template_project/.claude/ukit/runtime/compact-threshold.mjs +148 -44
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +10 -3
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +105 -21
- package/template_project/.claude/ukit/runtime/hook-payload-store.mjs +22 -8
- package/template_project/.claude/ukit/runtime/hook-process.mjs +120 -4
- package/template_project/.claude/ukit/runtime/hook-telemetry.mjs +10 -3
- package/template_project/.claude/ukit/runtime/hook-telemetry.sh +14 -1
- package/template_project/.claude/ukit/runtime/output-compression.mjs +13 -3
- package/template_project/.omp/hooks/pre/ukit-bridge.js +21 -2
- package/template_project/docs/UKIT_INTERNALS.md +14 -2
|
@@ -160,6 +160,7 @@ export async function sweepExecLedgerDir(dir, {
|
|
|
160
160
|
maxFiles = LEDGER_MAX_FILES,
|
|
161
161
|
maxEntries = LEDGER_SWEEP_MAX_ENTRIES,
|
|
162
162
|
maxRemovals = LEDGER_SWEEP_MAX_REMOVALS,
|
|
163
|
+
random = Math.random,
|
|
163
164
|
} = {}) {
|
|
164
165
|
let dirents;
|
|
165
166
|
try {
|
|
@@ -178,9 +179,15 @@ export async function sweepExecLedgerDir(dir, {
|
|
|
178
179
|
if (dirent.isFile()) eligibleNames.push(dirent.name);
|
|
179
180
|
}
|
|
180
181
|
const entries = [];
|
|
182
|
+
// FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
|
|
183
|
+
// random start so files past the first maxEntries are eventually pruned.
|
|
184
|
+
const total = eligibleNames.length;
|
|
185
|
+
const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
|
|
186
|
+
const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
|
|
187
|
+
const limit = Math.min(maxEntries, total);
|
|
181
188
|
let scanned = 0;
|
|
182
|
-
for (
|
|
183
|
-
|
|
189
|
+
for (let i = 0; i < limit; i += 1) {
|
|
190
|
+
const name = eligibleNames[(start + i) % total];
|
|
184
191
|
scanned += 1;
|
|
185
192
|
try {
|
|
186
193
|
const stats = await fs.stat(path.join(dir, name));
|
|
@@ -258,7 +265,7 @@ export async function maybeSweepExecLedgerDir(dir, {
|
|
|
258
265
|
? Math.min(1, Math.max(0, probability))
|
|
259
266
|
: ledgerSweepProbabilityFromEnv();
|
|
260
267
|
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
261
|
-
return { sampled: true, ...(await sweepExecLedgerDir(dir, options)) };
|
|
268
|
+
return { sampled: true, ...(await sweepExecLedgerDir(dir, { ...options, random })) };
|
|
262
269
|
}
|
|
263
270
|
|
|
264
271
|
function resumeIntentPath(projectRoot, sessionId) {
|
|
@@ -6,7 +6,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
6
6
|
// TASK-016: children run through the process-tree runner instead of bare
|
|
7
7
|
// spawnSync — a timed-out child is TERM→KILLed as a whole POSIX process group,
|
|
8
8
|
// so a bash wrapper can no longer orphan the Node grandchildren it spawned.
|
|
9
|
-
import { runHookProcess } from './hook-process.mjs';
|
|
9
|
+
import { runHookProcess, resolveHookCommand, SPAWN_UNAVAILABLE_CODES } from './hook-process.mjs';
|
|
10
10
|
// Repeated identical blocks (a model respawning an agent into the same refusal) get a
|
|
11
11
|
// "stop and report verbatim" directive appended. Advisory + fail-open; see block-breaker.mjs.
|
|
12
12
|
// Loaded dynamically so a partially-updated runtime (module missing) degrades to "no advice"
|
|
@@ -88,6 +88,7 @@ async function readStdinBounded() {
|
|
|
88
88
|
// 'signal' — the child died from a signal this runner did not send
|
|
89
89
|
// 'error' — the child never spawned (ENOENT and friends)
|
|
90
90
|
// 'budget-exhausted' — the script never ran: the chain total budget was spent
|
|
91
|
+
// 'spawn-unavailable' — win32 bash launcher not found or could not start the .sh script (no process ran); warned, never blocks
|
|
91
92
|
function chainFailureKind(processResult) {
|
|
92
93
|
switch (processResult?.failureKind) {
|
|
93
94
|
case 'overflow':
|
|
@@ -169,6 +170,18 @@ function parseScriptArg(arg) {
|
|
|
169
170
|
// 'timeout'. The deadline is enforced by Promise.race + AbortSignal; the
|
|
170
171
|
// rejection race keeps running in the background (in-proc code cannot be
|
|
171
172
|
// SIGKILLed) but its late return value is discarded.
|
|
173
|
+
// FR-004: warning for a step whose win32 bash launcher never started. Single
|
|
174
|
+
// Windows fixText — this classification is unreachable off win32 (`viaBash`).
|
|
175
|
+
const SPAWN_UNAVAILABLE_FIX =
|
|
176
|
+
'Fix: install Git for Windows (https://git-scm.com/download/win) or set UKIT_BASH_BIN to the full path of bash.exe.';
|
|
177
|
+
|
|
178
|
+
function spawnUnavailableWarning(scriptName, cause) {
|
|
179
|
+
const gateText = FAIL_CLOSED_SCRIPTS.has(scriptName)
|
|
180
|
+
? 'fail-closed gate NOT enforced'
|
|
181
|
+
: 'advisory hook skipped';
|
|
182
|
+
return `UKit WARNING: hook ${scriptName} could not be launched (${cause}) and was SKIPPED — ${gateText}. ${SPAWN_UNAVAILABLE_FIX}\n`;
|
|
183
|
+
}
|
|
184
|
+
|
|
172
185
|
async function runModuleStep({ scriptPath, payload, payloadText, projectRoot, env, deadlineMs }) {
|
|
173
186
|
const controller = new AbortController();
|
|
174
187
|
let timer;
|
|
@@ -296,11 +309,25 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
296
309
|
// (ask + exit 2). Under --emit-verdict the runner replays the direct
|
|
297
310
|
// Claude contract (deny + exit 0), so children must NOT see it.
|
|
298
311
|
...(chainMarker ? { UKIT_HOOK_CHAIN_RUNNER: '1' } : {}),
|
|
312
|
+
// BUG-3 (TASK-003): the runner owns hook-latency rows for every chained
|
|
313
|
+
// step (see recordHookTiming below). This marker keeps hook-telemetry.sh
|
|
314
|
+
// unarmed in `.sh` children so they spawn no `--finish` node/sleep.
|
|
315
|
+
// Distinct from UKIT_HOOK_CHAIN_RUNNER, which is withheld under
|
|
316
|
+
// --emit-verdict; this one is set in both modes. Direct invocations
|
|
317
|
+
// (no runner) never see it and stay armed.
|
|
318
|
+
UKIT_HOOK_TELEMETRY_BY_RUNNER: '1',
|
|
299
319
|
};
|
|
300
320
|
const stepDeadlineMs = Math.min(childBudgetsMs[scriptIndex], remainingMs);
|
|
301
321
|
// TASK-001: `.mjs` args run in-proc via runHook(ctx); `.sh` stays a child
|
|
302
322
|
// process. Both paths share deadline/budget, results[] shape, and break
|
|
303
323
|
// conditions.
|
|
324
|
+
// FR-003/FR-004: non-.mjs steps launch through the resolver (absolute Git
|
|
325
|
+
// Bash on win32 for `.sh`, the script itself everywhere else). `viaBash`
|
|
326
|
+
// is the ONLY thing that can turn a launch failure into spawn-unavailable.
|
|
327
|
+
const launch = scriptPath.endsWith('.mjs')
|
|
328
|
+
? { command: scriptPath, args: [], viaBash: false }
|
|
329
|
+
: resolveHookCommand(scriptPath);
|
|
330
|
+
const { viaBash } = launch;
|
|
304
331
|
const result = scriptPath.endsWith('.mjs')
|
|
305
332
|
? await runModuleStep({
|
|
306
333
|
scriptPath,
|
|
@@ -310,10 +337,13 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
310
337
|
env: stepEnv,
|
|
311
338
|
deadlineMs: stepDeadlineMs,
|
|
312
339
|
})
|
|
313
|
-
:
|
|
314
|
-
|
|
340
|
+
: launch.command === null
|
|
341
|
+
// No bash found on win32: nothing is spawned (never a bare `bash`).
|
|
342
|
+
? { code: 1, stdout: '', stderr: '', failureKind: 'error', spawnFailed: true, errorCode: null }
|
|
343
|
+
: await runHookProcess({
|
|
344
|
+
command: launch.command,
|
|
315
345
|
deadlineMs: stepDeadlineMs,
|
|
316
|
-
args:
|
|
346
|
+
args: launch.args,
|
|
317
347
|
input: payloadText,
|
|
318
348
|
maxBuffer: MAX_BUFFER_BYTES,
|
|
319
349
|
cwd: projectRoot,
|
|
@@ -324,6 +354,38 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
324
354
|
// decision JSON.
|
|
325
355
|
env: stepEnv,
|
|
326
356
|
});
|
|
357
|
+
const launchFailed = viaBash && (
|
|
358
|
+
launch.command === null
|
|
359
|
+
|| (result.spawnFailed === true && SPAWN_UNAVAILABLE_CODES.has(result.errorCode))
|
|
360
|
+
);
|
|
361
|
+
if (launchFailed && scriptName !== 'block-dangerous.sh' && fs.existsSync(scriptPath)) {
|
|
362
|
+
const stepElapsedMs = Date.now() - childStartedAt;
|
|
363
|
+
results.push({
|
|
364
|
+
scriptName,
|
|
365
|
+
code: 0,
|
|
366
|
+
stdout: '',
|
|
367
|
+
stderr: spawnUnavailableWarning(scriptName, launch.command === null ? 'bash.exe not found' : result.errorCode),
|
|
368
|
+
failureKind: 'spawn-unavailable',
|
|
369
|
+
killed: false,
|
|
370
|
+
elapsedMs: stepElapsedMs,
|
|
371
|
+
});
|
|
372
|
+
recordHookTiming({
|
|
373
|
+
projectRoot,
|
|
374
|
+
sessionId: payload?.session_id,
|
|
375
|
+
event: payload?.hook_event_name || null,
|
|
376
|
+
tool: payload?.tool_name || null,
|
|
377
|
+
toolUseId: payload?.tool_use_id || null,
|
|
378
|
+
hook: scriptName,
|
|
379
|
+
elapsedMs: stepElapsedMs,
|
|
380
|
+
outcome: 'spawn-unavailable',
|
|
381
|
+
});
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (launch.command === null) {
|
|
385
|
+
// Resolver found no bash but the step does not qualify for the degrade
|
|
386
|
+
// (script missing / block-dangerous.sh): fail closed exactly like a spawn error.
|
|
387
|
+
result.stderr = `UKit hook ${scriptName} could not be launched: bash.exe not found\n`;
|
|
388
|
+
}
|
|
327
389
|
const failureKind = chainFailureKind(result);
|
|
328
390
|
// FR-001: on infra failure (error/timeout — no module verdict) the result
|
|
329
391
|
// code is 2 when the step is fail-closed, else 1.
|
|
@@ -350,21 +412,21 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
350
412
|
elapsedMs: Date.now() - childStartedAt,
|
|
351
413
|
});
|
|
352
414
|
|
|
353
|
-
// TASK-001 (FR-003)
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
415
|
+
// TASK-001 (FR-003) / TASK-003 (BUG-3): the runner is the single telemetry
|
|
416
|
+
// owner for every chained step. `.mjs` steps run in-proc and cannot write
|
|
417
|
+
// their own row; `.sh` children are told via UKIT_HOOK_TELEMETRY_BY_RUNNER
|
|
418
|
+
// not to spawn hook-telemetry.mjs --finish. One row per step, hook =
|
|
419
|
+
// basename incl. extension; elapsedMs includes bash start-up.
|
|
420
|
+
recordHookTiming({
|
|
421
|
+
projectRoot,
|
|
422
|
+
sessionId: payload?.session_id,
|
|
423
|
+
event: payload?.hook_event_name || null,
|
|
424
|
+
tool: payload?.tool_name || null,
|
|
425
|
+
toolUseId: payload?.tool_use_id || null,
|
|
426
|
+
hook: scriptName,
|
|
427
|
+
elapsedMs: Date.now() - childStartedAt,
|
|
428
|
+
outcome: failureKind,
|
|
429
|
+
});
|
|
368
430
|
|
|
369
431
|
// B1 (TASK-001, SPEC §FR-001): under `--emit-verdict` a child that emits a
|
|
370
432
|
// `hookSpecificOutput` permission decision owns the verdict even at exit 0
|
|
@@ -602,18 +664,34 @@ try {
|
|
|
602
664
|
.filter((r) => r !== decisionResult && r !== last && typeof r.stdout === 'string' && r.stdout.length > 0)
|
|
603
665
|
.map((r) => r.stdout)
|
|
604
666
|
.join('');
|
|
667
|
+
// FR-005: spawn-unavailable steps never block, but must stay visible. Their
|
|
668
|
+
// warnings go to stderr exactly ONCE (here) and, where stdout is a clean
|
|
669
|
+
// JSON document the runner owns, into `systemMessage`.
|
|
670
|
+
const warnings = chain.results
|
|
671
|
+
.filter((r) => r.failureKind === 'spawn-unavailable')
|
|
672
|
+
.map((r) => r.stderr)
|
|
673
|
+
.join('');
|
|
674
|
+
if (warnings) process.stderr.write(warnings);
|
|
605
675
|
if (decisionOwnsVerdict) {
|
|
606
676
|
let verdictOut = decisionResult.stdout;
|
|
607
677
|
try {
|
|
608
678
|
const doc = JSON.parse(verdictOut);
|
|
609
679
|
const hso = doc?.hookSpecificOutput;
|
|
680
|
+
let changed = false;
|
|
610
681
|
if (hso?.permissionDecision === 'deny') {
|
|
611
682
|
const advice = breakerAdvice(hso.permissionDecisionReason);
|
|
612
683
|
if (advice) {
|
|
613
684
|
hso.permissionDecisionReason = `${hso.permissionDecisionReason}\n${advice}`;
|
|
614
|
-
|
|
685
|
+
changed = true;
|
|
615
686
|
}
|
|
616
687
|
}
|
|
688
|
+
if (warnings && doc && typeof doc === 'object' && !Array.isArray(doc)) {
|
|
689
|
+
doc.systemMessage = typeof doc.systemMessage === 'string' && doc.systemMessage
|
|
690
|
+
? `${doc.systemMessage}\n${warnings.trimEnd()}`
|
|
691
|
+
: warnings.trimEnd();
|
|
692
|
+
changed = true;
|
|
693
|
+
}
|
|
694
|
+
if (changed) verdictOut = JSON.stringify(doc);
|
|
617
695
|
} catch { /* unparseable verdict: replay it untouched */ }
|
|
618
696
|
process.stdout.write(verdictOut);
|
|
619
697
|
if (decisionResult.stderr) process.stderr.write(decisionResult.stderr);
|
|
@@ -662,8 +740,14 @@ try {
|
|
|
662
740
|
} else {
|
|
663
741
|
if (contextStdout) process.stdout.write(contextStdout);
|
|
664
742
|
if (last.stdout) process.stdout.write(last.stdout);
|
|
665
|
-
|
|
743
|
+
// A spawn-unavailable `last` carries only the warning already written above.
|
|
744
|
+
if (last.stderr && last.failureKind !== 'spawn-unavailable') process.stderr.write(last.stderr);
|
|
666
745
|
process.exitCode = last.code === 0 ? 0 : (lastIsFailClosed ? 2 : 0);
|
|
746
|
+
// FR-005(c): exiting 0 with an otherwise empty stdout — surface the warning
|
|
747
|
+
// to the host as a systemMessage. Plain context stdout is left untouched.
|
|
748
|
+
if (warnings && process.exitCode === 0 && !contextStdout && !last.stdout) {
|
|
749
|
+
process.stdout.write(JSON.stringify({ systemMessage: warnings.trimEnd() }));
|
|
750
|
+
}
|
|
667
751
|
}
|
|
668
752
|
}
|
|
669
753
|
}
|
|
@@ -195,6 +195,7 @@ export async function sweepStalePayloadsAsync(dir, {
|
|
|
195
195
|
now = Date.now,
|
|
196
196
|
maxAgeMs = SWEEP_MAX_AGE_MS,
|
|
197
197
|
maxEntries = SWEEP_MAX_ENTRIES,
|
|
198
|
+
random = Math.random,
|
|
198
199
|
} = {}) {
|
|
199
200
|
let names;
|
|
200
201
|
try {
|
|
@@ -203,12 +204,18 @@ export async function sweepStalePayloadsAsync(dir, {
|
|
|
203
204
|
return { sampled: true, scanned: 0, removed: 0 };
|
|
204
205
|
}
|
|
205
206
|
const cutoff = now() - maxAgeMs;
|
|
207
|
+
const eligibleNames = names.filter((name) => name.endsWith('.json') || name.endsWith('.tmp'));
|
|
208
|
+
// FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
|
|
209
|
+
// random start so files past the first maxEntries are eventually pruned.
|
|
210
|
+
const total = eligibleNames.length;
|
|
211
|
+
const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
|
|
212
|
+
const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
|
|
213
|
+
const limit = Math.min(maxEntries, total);
|
|
206
214
|
let scanned = 0;
|
|
207
215
|
let removed = 0;
|
|
208
|
-
for (
|
|
209
|
-
|
|
216
|
+
for (let i = 0; i < limit; i += 1) {
|
|
217
|
+
const name = eligibleNames[(start + i) % total];
|
|
210
218
|
scanned += 1;
|
|
211
|
-
if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
|
|
212
219
|
const filePath = path.join(dir, name);
|
|
213
220
|
try {
|
|
214
221
|
if ((await fsp.stat(filePath)).mtimeMs < cutoff) {
|
|
@@ -234,7 +241,7 @@ export async function maybeSweepStalePayloadsAsync(dir, {
|
|
|
234
241
|
} = {}) {
|
|
235
242
|
const p = Number.isFinite(probability) ? Math.min(1, Math.max(0, probability)) : sweepProbabilityFromEnv();
|
|
236
243
|
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
237
|
-
return sweepStalePayloadsAsync(dir, { now, maxAgeMs, maxEntries });
|
|
244
|
+
return sweepStalePayloadsAsync(dir, { now, maxAgeMs, maxEntries, random });
|
|
238
245
|
}
|
|
239
246
|
|
|
240
247
|
// sweepStalePayloads(dir, {now, maxAgeMs, maxEntries}) -> {sampled, scanned, removed}
|
|
@@ -247,6 +254,7 @@ export function sweepStalePayloads(dir, {
|
|
|
247
254
|
now = Date.now,
|
|
248
255
|
maxAgeMs = SWEEP_MAX_AGE_MS,
|
|
249
256
|
maxEntries = SWEEP_MAX_ENTRIES,
|
|
257
|
+
random = Math.random,
|
|
250
258
|
} = {}) {
|
|
251
259
|
let names;
|
|
252
260
|
try {
|
|
@@ -255,12 +263,18 @@ export function sweepStalePayloads(dir, {
|
|
|
255
263
|
return { sampled: true, scanned: 0, removed: 0 };
|
|
256
264
|
}
|
|
257
265
|
const cutoff = now() - maxAgeMs;
|
|
266
|
+
const eligibleNames = names.filter((name) => name.endsWith('.json') || name.endsWith('.tmp'));
|
|
267
|
+
// FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
|
|
268
|
+
// random start so files past the first maxEntries are eventually pruned.
|
|
269
|
+
const total = eligibleNames.length;
|
|
270
|
+
const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
|
|
271
|
+
const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
|
|
272
|
+
const limit = Math.min(maxEntries, total);
|
|
258
273
|
let scanned = 0;
|
|
259
274
|
let removed = 0;
|
|
260
|
-
for (
|
|
261
|
-
|
|
275
|
+
for (let i = 0; i < limit; i += 1) {
|
|
276
|
+
const name = eligibleNames[(start + i) % total];
|
|
262
277
|
scanned += 1;
|
|
263
|
-
if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
|
|
264
278
|
const filePath = path.join(dir, name);
|
|
265
279
|
try {
|
|
266
280
|
if (fs.statSync(filePath).mtimeMs < cutoff) {
|
|
@@ -287,5 +301,5 @@ export function maybeSweepStalePayloads(dir, {
|
|
|
287
301
|
} = {}) {
|
|
288
302
|
const p = Number.isFinite(probability) ? Math.min(1, Math.max(0, probability)) : sweepProbabilityFromEnv();
|
|
289
303
|
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
290
|
-
return sweepStalePayloads(dir, { now, maxAgeMs, maxEntries });
|
|
304
|
+
return sweepStalePayloads(dir, { now, maxAgeMs, maxEntries, random });
|
|
291
305
|
}
|
|
@@ -18,15 +18,29 @@
|
|
|
18
18
|
// assumes group semantics.
|
|
19
19
|
//
|
|
20
20
|
// Contract (TASK-016 Interfaces):
|
|
21
|
-
// runHookProcess({command, args, input, deadlineMs, maxBuffer, ...}) ->
|
|
22
|
-
// Promise<{code, signal, stdout, stderr, failureKind, elapsedMs}>
|
|
21
|
+
// runHookProcess({command, args, input, deadlineMs, maxBuffer, spawnImpl, ...}) ->
|
|
22
|
+
// Promise<{code, signal, stdout, stderr, failureKind, elapsedMs, spawnFailed, errorCode}>
|
|
23
23
|
// failureKind: 'none' | 'deadline' | 'signal' | 'overflow' | 'error'
|
|
24
24
|
// 'deadline' — this runner killed the child because the deadline expired
|
|
25
25
|
// 'signal' — the child died from a signal this runner did not send
|
|
26
26
|
// 'overflow' — captured output exceeded maxBuffer; the tree was culled
|
|
27
27
|
// 'error' — the child never spawned (ENOENT and friends)
|
|
28
|
+
// spawnFailed: true only when the process was NEVER started — derived from child
|
|
29
|
+
// event order (an 'error' with no preceding 'spawn' event, or spawn() throwing
|
|
30
|
+
// synchronously), never from timing. An 'error' after 'spawn' is not a spawn failure.
|
|
31
|
+
// errorCode: the error's `code` (ENOENT, EINVAL, EPIPE, ...) or null.
|
|
32
|
+
// spawnImpl: injectable spawn function (default child_process.spawn) for tests.
|
|
33
|
+
//
|
|
34
|
+
// Windows .sh launching (resolveHookCommand / resolveWindowsBash):
|
|
35
|
+
// Windows cannot exec a `.sh` file directly, so on win32 a `.sh` hook is launched
|
|
36
|
+
// through an ABSOLUTE Git Bash path (never a bare `bash`, which Windows would look up
|
|
37
|
+
// in the child cwd first). Script paths are passed with forward slashes. POSIX spawns
|
|
38
|
+
// the script directly, unchanged. UKIT_HOOK_SHELL_PLATFORM overrides the platform seen
|
|
39
|
+
// by resolveHookCommand only (tests/diagnostics); UKIT_BASH_BIN overrides the bash path.
|
|
28
40
|
|
|
29
41
|
import { spawn } from 'node:child_process';
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
30
44
|
|
|
31
45
|
// POSIX only. Windows kill() targets the direct child; process groups do not
|
|
32
46
|
// exist there, so pretending otherwise would be a silent, wrong assumption.
|
|
@@ -41,6 +55,93 @@ export const KILL_GRACE_MS = 500;
|
|
|
41
55
|
// runner past its own budget.
|
|
42
56
|
const HARD_SETTLE_SLACK_MS = 2000;
|
|
43
57
|
|
|
58
|
+
// Error codes meaning "the launcher itself could not be started".
|
|
59
|
+
export const SPAWN_UNAVAILABLE_CODES = new Set([
|
|
60
|
+
'ENOENT',
|
|
61
|
+
'EINVAL',
|
|
62
|
+
'EACCES',
|
|
63
|
+
'EPERM',
|
|
64
|
+
'ENOEXEC',
|
|
65
|
+
'EFTYPE',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/** Windows path -> forward slashes, so bash's dirname/$0 logic works. */
|
|
69
|
+
export function toBashPath(p) {
|
|
70
|
+
return String(p).replace(/\\/g, '/');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const win32 = path.win32;
|
|
74
|
+
|
|
75
|
+
function safeExists(exists, candidate) {
|
|
76
|
+
try {
|
|
77
|
+
return Boolean(exists(candidate));
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Locate Git Bash on Windows. Returns an absolute path or null — never a bare
|
|
85
|
+
* or relative name (Windows resolves bare names in the child cwd first, which
|
|
86
|
+
* would let a planted bash.exe in a cloned repo run).
|
|
87
|
+
*/
|
|
88
|
+
export function resolveWindowsBash({ env = process.env, exists = fs.existsSync } = {}) {
|
|
89
|
+
const override = env.UKIT_BASH_BIN;
|
|
90
|
+
if (typeof override === 'string' && override && win32.isAbsolute(override)) return override;
|
|
91
|
+
|
|
92
|
+
const probe = (candidate) =>
|
|
93
|
+
typeof candidate === 'string' &&
|
|
94
|
+
candidate !== '' &&
|
|
95
|
+
win32.isAbsolute(candidate) &&
|
|
96
|
+
safeExists(exists, candidate);
|
|
97
|
+
|
|
98
|
+
const claude = env.CLAUDE_CODE_GIT_BASH_PATH;
|
|
99
|
+
if (probe(claude)) return claude;
|
|
100
|
+
|
|
101
|
+
const defaults = [
|
|
102
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
103
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
104
|
+
];
|
|
105
|
+
if (typeof env.LOCALAPPDATA === 'string' && env.LOCALAPPDATA) {
|
|
106
|
+
defaults.push(win32.join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'));
|
|
107
|
+
}
|
|
108
|
+
for (const candidate of defaults) {
|
|
109
|
+
if (probe(candidate)) return candidate;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const trimSlash = (p) => p.replace(/\\+$/, '').toLowerCase();
|
|
113
|
+
const system32 = trimSlash(win32.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32'));
|
|
114
|
+
const rawPath = env.PATH ?? env.Path ?? '';
|
|
115
|
+
for (const rawEntry of String(rawPath).split(';')) {
|
|
116
|
+
const entry = rawEntry.trim().replace(/^"+|"+$/g, '').trim();
|
|
117
|
+
if (!entry || !win32.isAbsolute(entry)) continue;
|
|
118
|
+
const norm = trimSlash(win32.normalize(entry));
|
|
119
|
+
if (norm === system32 || norm.endsWith('\\microsoft\\windowsapps')) continue;
|
|
120
|
+
const candidate = win32.join(entry, 'bash.exe');
|
|
121
|
+
if (safeExists(exists, candidate)) return candidate;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Decide how to launch a hook script. win32 + `.sh` -> Git Bash (command may be
|
|
128
|
+
* null when none was found); everything else spawns the script directly.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveHookCommand(
|
|
131
|
+
scriptPath,
|
|
132
|
+
{ env = process.env, platform = env.UKIT_HOOK_SHELL_PLATFORM || process.platform, exists = fs.existsSync } = {},
|
|
133
|
+
) {
|
|
134
|
+
if (typeof scriptPath !== 'string') return { command: '', args: [], viaBash: false };
|
|
135
|
+
if (platform === 'win32' && /\.sh$/i.test(scriptPath)) {
|
|
136
|
+
return {
|
|
137
|
+
command: resolveWindowsBash({ env, exists }),
|
|
138
|
+
args: [toBashPath(scriptPath)],
|
|
139
|
+
viaBash: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return { command: scriptPath, args: [], viaBash: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
44
145
|
/**
|
|
45
146
|
* Signal a process GROUP by pid (negative-pid kill). Swallows ESRCH — the
|
|
46
147
|
* child may have exited between arming the timer and this call, or the pid may
|
|
@@ -73,18 +174,20 @@ export function runHookProcess({
|
|
|
73
174
|
cwd,
|
|
74
175
|
env = process.env,
|
|
75
176
|
graceMs = KILL_GRACE_MS,
|
|
177
|
+
spawnImpl = spawn,
|
|
76
178
|
}) {
|
|
77
179
|
return new Promise((resolve) => {
|
|
78
180
|
const startedAt = Date.now();
|
|
79
181
|
let child;
|
|
80
182
|
try {
|
|
81
|
-
child =
|
|
183
|
+
child = spawnImpl(command, args, {
|
|
82
184
|
cwd,
|
|
83
185
|
env,
|
|
84
186
|
// Detached ONLY on POSIX: the child becomes a group leader so the whole
|
|
85
187
|
// tree dies together. Windows never gets a group assumption.
|
|
86
188
|
detached: GROUP_KILL_SUPPORTED,
|
|
87
189
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
190
|
+
windowsHide: true,
|
|
88
191
|
});
|
|
89
192
|
} catch (err) {
|
|
90
193
|
resolve({
|
|
@@ -94,6 +197,8 @@ export function runHookProcess({
|
|
|
94
197
|
stderr: String(err?.message || err),
|
|
95
198
|
failureKind: 'error',
|
|
96
199
|
elapsedMs: Date.now() - startedAt,
|
|
200
|
+
spawnFailed: true,
|
|
201
|
+
errorCode: err?.code ?? null,
|
|
97
202
|
});
|
|
98
203
|
return;
|
|
99
204
|
}
|
|
@@ -104,6 +209,8 @@ export function runHookProcess({
|
|
|
104
209
|
let hardSettled = false;
|
|
105
210
|
let overflowed = false;
|
|
106
211
|
let spawnError = null;
|
|
212
|
+
let spawned = false;
|
|
213
|
+
let spawnFailed = false;
|
|
107
214
|
let stdoutBuf = Buffer.alloc(0);
|
|
108
215
|
let stderrBuf = Buffer.alloc(0);
|
|
109
216
|
const stdoutRef = { value: stdoutBuf };
|
|
@@ -146,6 +253,8 @@ export function runHookProcess({
|
|
|
146
253
|
stderr: stderrRef.value.toString('utf8'),
|
|
147
254
|
failureKind,
|
|
148
255
|
elapsedMs: Date.now() - startedAt,
|
|
256
|
+
spawnFailed,
|
|
257
|
+
errorCode: spawnError?.code ?? null,
|
|
149
258
|
});
|
|
150
259
|
};
|
|
151
260
|
|
|
@@ -238,9 +347,16 @@ export function runHookProcess({
|
|
|
238
347
|
}, deadlineMs + Math.max(0, graceMs) + HARD_SETTLE_SLACK_MS);
|
|
239
348
|
}
|
|
240
349
|
|
|
350
|
+
// Event order, not timing, tells "never started" from "failed after start".
|
|
351
|
+
child.once('spawn', () => {
|
|
352
|
+
spawned = true;
|
|
353
|
+
});
|
|
241
354
|
child.on('error', (err) => {
|
|
242
355
|
// Spawn failures (ENOENT and friends) emit 'error' without 'close'.
|
|
243
|
-
if (!spawnError)
|
|
356
|
+
if (!spawnError) {
|
|
357
|
+
spawnError = err;
|
|
358
|
+
spawnFailed = !spawned;
|
|
359
|
+
}
|
|
244
360
|
finish();
|
|
245
361
|
});
|
|
246
362
|
child.on('close', () => {
|
|
@@ -231,6 +231,7 @@ export function sweepTelemetryDir(dir, {
|
|
|
231
231
|
maxFiles = TELEMETRY_MAX_FILES,
|
|
232
232
|
maxEntries = TELEMETRY_SWEEP_MAX_ENTRIES,
|
|
233
233
|
maxRemovals = TELEMETRY_SWEEP_MAX_REMOVALS,
|
|
234
|
+
random = Math.random,
|
|
234
235
|
} = {}) {
|
|
235
236
|
let names;
|
|
236
237
|
try {
|
|
@@ -245,9 +246,15 @@ export function sweepTelemetryDir(dir, {
|
|
|
245
246
|
// `entries.length` an under-count and `overflow` was always 0.
|
|
246
247
|
const eligibleNames = names.filter((name) => name.endsWith('.jsonl'));
|
|
247
248
|
const entries = [];
|
|
249
|
+
// FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
|
|
250
|
+
// random start so files past the first maxEntries are eventually pruned.
|
|
251
|
+
const total = eligibleNames.length;
|
|
252
|
+
const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
|
|
253
|
+
const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
|
|
254
|
+
const limit = Math.min(maxEntries, total);
|
|
248
255
|
let scanned = 0;
|
|
249
|
-
for (
|
|
250
|
-
|
|
256
|
+
for (let i = 0; i < limit; i += 1) {
|
|
257
|
+
const name = eligibleNames[(start + i) % total];
|
|
251
258
|
scanned += 1;
|
|
252
259
|
try {
|
|
253
260
|
const stats = fs.statSync(path.join(dir, name));
|
|
@@ -283,7 +290,7 @@ export function maybeSweepTelemetryDir(dir, {
|
|
|
283
290
|
? Math.min(1, Math.max(0, probability))
|
|
284
291
|
: telemetrySweepProbabilityFromEnv();
|
|
285
292
|
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
286
|
-
return { sampled: true, ...sweepTelemetryDir(dir, options) };
|
|
293
|
+
return { sampled: true, ...sweepTelemetryDir(dir, { ...options, random }) };
|
|
287
294
|
}
|
|
288
295
|
|
|
289
296
|
// BUG-C23-10: a bare `catch {}` around the sweep swallowed every failure — a
|
|
@@ -17,10 +17,21 @@
|
|
|
17
17
|
# without adding a node spawn to the hot path — and trap
|
|
18
18
|
# ukit_hook_telemetry_finish on EXIT themselves.
|
|
19
19
|
#
|
|
20
|
+
# BUG-3 (TASK-003): inside hook-chain-runner.mjs the runner is the single
|
|
21
|
+
# telemetry owner — it times every step and writes the hook-latency row itself.
|
|
22
|
+
# It exports UKIT_HOOK_TELEMETRY_BY_RUNNER=1 to each child; when that marker is
|
|
23
|
+
# 1 this file stays unarmed (UKIT_TEL_ARMED=0) and arm/finish return at once —
|
|
24
|
+
# no mktemp, no node, no sleep watchdog. Direct invocations (Codex direct hooks,
|
|
25
|
+
# debug runs) carry no marker and behave exactly as before.
|
|
26
|
+
#
|
|
20
27
|
# Missing runtime (pre-install tree) simply leaves telemetry off: wrappers
|
|
21
28
|
# source this file with `|| true` and the armed flag stays unset.
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
if [ "${UKIT_HOOK_TELEMETRY_BY_RUNNER:-}" = "1" ]; then
|
|
31
|
+
UKIT_TEL_ARMED=0
|
|
32
|
+
else
|
|
33
|
+
UKIT_TEL_ARMED=1
|
|
34
|
+
fi
|
|
24
35
|
UKIT_TEL_HOOK="$(basename "${BASH_SOURCE[1]:-$0}")"
|
|
25
36
|
UKIT_TEL_RUNTIME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
26
37
|
UKIT_TEL_START_FILE=""
|
|
@@ -52,6 +63,7 @@ ukit_tel_file_ms() {
|
|
|
52
63
|
# one mktemp; a `node -e` clock read would cost ~30ms to measure milliseconds.
|
|
53
64
|
# Always returns 0 — an unarmed hook must behave exactly like an unmeasured one.
|
|
54
65
|
ukit_hook_telemetry_arm() {
|
|
66
|
+
[ "${UKIT_TEL_ARMED:-}" = "1" ] || return 0
|
|
55
67
|
UKIT_TEL_START_FILE="$(mktemp "${TMPDIR:-/tmp}/ukit-tel-start.XXXXXX" 2>/dev/null)" || {
|
|
56
68
|
UKIT_TEL_START_FILE=""
|
|
57
69
|
return 0
|
|
@@ -65,6 +77,7 @@ ukit_hook_telemetry_finish() {
|
|
|
65
77
|
# captured it before its own conditionals could reset $?); $? is only the
|
|
66
78
|
# fallback for direct debug invocation.
|
|
67
79
|
local rc="${UKIT_TEL_RC:-$?}"
|
|
80
|
+
[ "${UKIT_TEL_ARMED:-}" = "1" ] || return 0
|
|
68
81
|
command -v node >/dev/null 2>&1 || return 0
|
|
69
82
|
# UKIT_INPUT_FILE is a plain shell variable (never exported), so the staged
|
|
70
83
|
# payload path — and through it the envelope start mtime — is passed here
|
|
@@ -455,6 +455,7 @@ export async function sweepTeeCache(dir, {
|
|
|
455
455
|
maxFiles = TEE_MAX_FILES,
|
|
456
456
|
maxEntries = TEE_SWEEP_MAX_ENTRIES,
|
|
457
457
|
maxRemovals = TEE_SWEEP_MAX_REMOVALS,
|
|
458
|
+
random = Math.random,
|
|
458
459
|
} = {}) {
|
|
459
460
|
let dirents;
|
|
460
461
|
try {
|
|
@@ -478,8 +479,17 @@ export async function sweepTeeCache(dir, {
|
|
|
478
479
|
const cutoff = now() - maxAgeMs;
|
|
479
480
|
const entries = [];
|
|
480
481
|
let scanned = 0;
|
|
481
|
-
|
|
482
|
-
|
|
482
|
+
// Circular window at a random start so expired files beyond the first
|
|
483
|
+
// maxEntries readdir names are still reached (BUG-1: the head slice never
|
|
484
|
+
// age-pruned the tail). Non-finite random() falls back to start 0.
|
|
485
|
+
const L = eligibleNames.length;
|
|
486
|
+
const r = random();
|
|
487
|
+
const start = L > maxEntries
|
|
488
|
+
? Math.min(L - 1, Math.max(0, Math.floor((Number.isFinite(r) ? r : 0) * L)))
|
|
489
|
+
: 0;
|
|
490
|
+
const windowSize = Math.min(maxEntries, L);
|
|
491
|
+
for (let i = 0; i < windowSize; i += 1) {
|
|
492
|
+
const name = eligibleNames[(start + i) % L];
|
|
483
493
|
scanned += 1;
|
|
484
494
|
try {
|
|
485
495
|
const stats = await fs.stat(path.join(dir, name));
|
|
@@ -516,7 +526,7 @@ export async function maybeSweepTeeCache(dir, {
|
|
|
516
526
|
? Math.min(1, Math.max(0, probability))
|
|
517
527
|
: teeSweepProbabilityFromEnv();
|
|
518
528
|
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
519
|
-
return sweepTeeCache(dir, options);
|
|
529
|
+
return sweepTeeCache(dir, { ...options, random });
|
|
520
530
|
}
|
|
521
531
|
|
|
522
532
|
async function persistRawOutput(projectRoot, {
|
|
@@ -273,6 +273,18 @@ function parseStructuredDecision(stdout) {
|
|
|
273
273
|
|
|
274
274
|
function translateExecResult(scriptName, execResult) {
|
|
275
275
|
const failureKind = typeof execResult?.failureKind === 'string' ? execResult.failureKind : null;
|
|
276
|
+
// The runner could not launch a .sh hook at all (win32 without bash): a loud warning,
|
|
277
|
+
// never a block, and no captured content travels with it.
|
|
278
|
+
if (failureKind === 'spawn-unavailable') {
|
|
279
|
+
const launchStderr = typeof execResult?.stderr === 'string' ? execResult.stderr : '';
|
|
280
|
+
return {
|
|
281
|
+
block: false,
|
|
282
|
+
warning: launchStderr.trim()
|
|
283
|
+
|| `${scriptName} could not be launched and was skipped (spawn-unavailable) — install Git for Windows or set UKIT_BASH_BIN.`,
|
|
284
|
+
stdout: '',
|
|
285
|
+
stderr: launchStderr,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
276
288
|
// Infrastructure outcomes produce no verdict: no captured content travels with
|
|
277
289
|
// them (overflowed stdout is truncated mid-stream and may embed secrets).
|
|
278
290
|
const infrastructure = failureKind
|
|
@@ -454,6 +466,7 @@ async function sweepHookErrorsDir(dir, {
|
|
|
454
466
|
maxFiles = HOOK_ERROR_MAX_FILES,
|
|
455
467
|
maxEntries = HOOK_ERROR_SWEEP_MAX_ENTRIES,
|
|
456
468
|
maxRemovals = HOOK_ERROR_SWEEP_MAX_REMOVALS,
|
|
469
|
+
random = Math.random,
|
|
457
470
|
} = {}) {
|
|
458
471
|
let names;
|
|
459
472
|
try {
|
|
@@ -468,9 +481,15 @@ async function sweepHookErrorsDir(dir, {
|
|
|
468
481
|
// `entries.length` an under-count and `overflow` was always 0.
|
|
469
482
|
const eligibleNames = names.filter((name) => name.endsWith('.jsonl'));
|
|
470
483
|
const entries = [];
|
|
484
|
+
// FR-002 (BUG-2): scan a circular window of at most maxEntries names from a
|
|
485
|
+
// random start so files past the first maxEntries are eventually pruned.
|
|
486
|
+
const total = eligibleNames.length;
|
|
487
|
+
const rawStart = total > maxEntries ? Math.floor(random() * total) : 0;
|
|
488
|
+
const start = Number.isFinite(rawStart) ? Math.min(total - 1, Math.max(0, rawStart)) : 0;
|
|
489
|
+
const limit = Math.min(maxEntries, total);
|
|
471
490
|
let scanned = 0;
|
|
472
|
-
for (
|
|
473
|
-
|
|
491
|
+
for (let i = 0; i < limit; i += 1) {
|
|
492
|
+
const name = eligibleNames[(start + i) % total];
|
|
474
493
|
scanned += 1;
|
|
475
494
|
try {
|
|
476
495
|
const stats = await fs.promises.stat(path.join(dir, name));
|