toolfunnel 0.6.0 → 0.7.0

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.
Files changed (42) hide show
  1. package/README.md +11 -7
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +97 -18
  4. package/docs/KNOWN_BUGS.md +71 -90
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +24 -10
  7. package/hooks/hooks.manifest.json +1 -1
  8. package/hooks/scripts/example-deny-dangerous.js +12 -4
  9. package/mcp/servers/mock-upstream/server.js +36 -6
  10. package/package.json +67 -65
  11. package/src/auth/config.js +2 -2
  12. package/src/auth/resource-server.js +12 -3
  13. package/src/core/child-reaper.js +61 -0
  14. package/src/core/events.js +7 -7
  15. package/src/core/hook-engine.js +85 -31
  16. package/src/core/hook-loader.js +46 -13
  17. package/src/core/hook-runner.js +258 -38
  18. package/src/core/logger.js +138 -12
  19. package/src/core/matcher.js +59 -10
  20. package/src/core/rename-migration.js +163 -0
  21. package/src/core/requires.js +1 -1
  22. package/src/extend/add-hook.md +2 -2
  23. package/src/extend/add-mcp.md +3 -6
  24. package/src/extend/configure.md +2 -1
  25. package/src/extend/create-tool.md +1 -1
  26. package/src/extend/howto.js +3 -3
  27. package/src/extend/package.md +11 -8
  28. package/src/mcp/aggregator.js +480 -52
  29. package/src/mcp/expose-store.js +130 -13
  30. package/src/mcp/gated-run.js +26 -17
  31. package/src/mcp/http-transport.js +274 -22
  32. package/src/mcp/mcp-client.js +194 -31
  33. package/src/mcp/modern.js +44 -10
  34. package/src/mcp/server.js +513 -89
  35. package/src/tools/registry.js +172 -24
  36. package/src/ui/public/app.js +17 -4
  37. package/src/ui/server.js +297 -29
  38. package/tools/scripts/tf-hook-set.js +11 -3
  39. package/tools/tools.register.json +227 -227
  40. package/src/core/tool-registry.js +0 -180
  41. package/src/packages/packager.js +0 -715
  42. package/src/tools/drift.js +0 -138
@@ -1,13 +1,13 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * registry.js - the dynamic, persisted Tool Register (see the architecture doc §5).
4
+ * registry.js - the dynamic, persisted Tool Register.
5
5
  *
6
- * This is distinct from src/core/tool-registry.js: that one is the in-memory
6
+ * This is distinct from the in-memory
7
7
  * catalogue of tools handed to a backend at connect time. THIS one is the
8
8
  * structured, on-disk register the lean meta-tools (toolfunnel_list_tools /
9
9
  * toolfunnel_tool_instructions / toolfunnel_run_tool) read every call - so register edits
10
- * are visible with no reconnect (§1, "no restart for register changes").
10
+ * are visible with no reconnect ("no restart for register changes").
11
11
  *
12
12
  * Register entry shape (tools.register.json -> { version, description, tools:[] }):
13
13
  * { id, name, summary, category, instructions, invoke, mode? }
@@ -28,7 +28,7 @@
28
28
  * add(entry) / update(id,patch) / remove(id) -> mutate + ATOMIC persist
29
29
  * resolveExecution(id, args) -> { type, run: () => Promise<result> }
30
30
  * describes HOW to run the invoke. It does NOT
31
- * gate - gating is gated-run.js's job (§9).
31
+ * gate - gating is gated-run.js's job.
32
32
  *
33
33
  * Pure-ish: the registry only touches its own JSON file + (for script invokes)
34
34
  * spawns node on a path resolved under this src/tools dir. Dependencies that a
@@ -39,7 +39,42 @@
39
39
 
40
40
  const fs = require('node:fs');
41
41
  const path = require('node:path');
42
- const { spawn } = require('node:child_process');
42
+ const { spawn, execFile } = require('node:child_process');
43
+ const reaper = require('../core/child-reaper');
44
+ const { migrateForStoreFile } = require('../core/rename-migration');
45
+
46
+ // LOCAL tool execution bounds (0.7.0). The upstream path has had per-method timeouts since 0.6.0;
47
+ // the local executor had none, and stdio serialises on one promise chain, so a hanging local tool
48
+ // starved every later request (including `ping`, which clients use as a liveness probe). 120 s
49
+ // matches the upstream PAYLOAD window so a slow-but-alive local tool behaves like a slow upstream.
50
+ // Test seam: the suite proves the timeout/cap BEHAVIOUR (not just these names) by shrinking the
51
+ // windows via env; production never sets them.
52
+ const TOOL_TIMEOUT_MS = Number(process.env.TOOLFUNNEL_TOOL_TIMEOUT_MS) > 0
53
+ ? Number(process.env.TOOLFUNNEL_TOOL_TIMEOUT_MS) : 120000;
54
+ const MAX_TOOL_OUTPUT_BYTES = Number(process.env.TOOLFUNNEL_TOOL_OUTPUT_CAP) > 0
55
+ ? Number(process.env.TOOLFUNNEL_TOOL_OUTPUT_CAP) : 4 * 1024 * 1024;
56
+
57
+ /**
58
+ * Kill a spawned child AND its descendants, by each platform's own mechanism: taskkill /T on
59
+ * Windows, the process group on POSIX (the spawn sets `detached` there so the child leads one).
60
+ * Killing only the direct child orphans grandchildren, which then hold the stdio pipes open.
61
+ * Best-effort and never throws.
62
+ * @param {import('node:child_process').ChildProcess} child
63
+ */
64
+ function killTree(child) {
65
+ if (!child) return;
66
+ try {
67
+ if (child.pid != null && process.platform === 'win32') {
68
+ execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
69
+ } else if (child.pid != null) {
70
+ try { process.kill(-child.pid, 'SIGKILL'); } catch (_eg) { child.kill('SIGKILL'); }
71
+ } else {
72
+ child.kill('SIGKILL');
73
+ }
74
+ } catch (_e) {
75
+ try { child.kill(); } catch (_e2) { /* nothing more we can do */ }
76
+ }
77
+ }
43
78
 
44
79
  const VALID_INVOKE_TYPES = new Set(['script', 'shell']);
45
80
 
@@ -71,25 +106,60 @@ function cloneEntry(entry) {
71
106
  return JSON.parse(JSON.stringify(entry));
72
107
  }
73
108
 
109
+ /**
110
+ * A sibling temp older than this is a STRANDING (a crashed/killed writer), not an
111
+ * in-flight write - a live writer's temp exists for milliseconds. Swept on the next
112
+ * successful write of the same target; the generous age keeps a concurrent writer safe.
113
+ */
114
+ const STALE_TMP_MS = 60 * 60 * 1000;
115
+
116
+ /**
117
+ * Remove STALE stranded temps for `base` in `dir` (the `.<base>.<pid>.<ts>.tmp` shape the
118
+ * atomic writers below produce). Scoped to the target's own temp shape - never another
119
+ * file's - and entirely best-effort: sweeping must never fail a write.
120
+ */
121
+ function sweepStaleTmp(dir, base) {
122
+ let names;
123
+ try { names = fs.readdirSync(dir); } catch (_e) { return; }
124
+ const prefix = `.${base}.`;
125
+ const now = Date.now();
126
+ for (const name of names) {
127
+ if (!name.startsWith(prefix) || !name.endsWith('.tmp')) continue;
128
+ const p = path.join(dir, name);
129
+ try {
130
+ if (now - fs.statSync(p).mtimeMs > STALE_TMP_MS) fs.unlinkSync(p);
131
+ } catch (_e) { /* a concurrent writer renamed/removed it - fine */ }
132
+ }
133
+ }
134
+
74
135
  /**
75
136
  * Atomic write: serialise to a temp file in the SAME directory as the target
76
137
  * (so rename is atomic on the same filesystem/drive), fsync, then rename over
77
138
  * the target. A crash leaves either the old file or the new file, never a
78
- * half-written register.
139
+ * half-written register. The temp NEVER outlives a failure: any throw (a refused
140
+ * rename - win32 scanners hold targets briefly - a full disk, a write error)
141
+ * unlinks it before rethrowing, and stale strandings from writers that died
142
+ * mid-flight are swept on the next successful write (sweepStaleTmp above).
79
143
  */
80
144
  function atomicWriteJson(targetPath, obj) {
81
145
  const dir = path.dirname(targetPath);
82
146
  const base = path.basename(targetPath);
147
+ sweepStaleTmp(dir, base);
83
148
  const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
84
149
  const data = JSON.stringify(obj, null, 2) + '\n';
85
- const fd = fs.openSync(tmp, 'w');
86
150
  try {
87
- fs.writeSync(fd, data, 0, 'utf8');
88
- fs.fsyncSync(fd);
89
- } finally {
90
- fs.closeSync(fd);
151
+ const fd = fs.openSync(tmp, 'w');
152
+ try {
153
+ fs.writeSync(fd, data, 0, 'utf8');
154
+ fs.fsyncSync(fd);
155
+ } finally {
156
+ fs.closeSync(fd);
157
+ }
158
+ fs.renameSync(tmp, targetPath);
159
+ } catch (err) {
160
+ try { fs.unlinkSync(tmp); } catch (_e) { /* best-effort - the error below is the story */ }
161
+ throw err;
91
162
  }
92
- fs.renameSync(tmp, targetPath);
93
163
  }
94
164
 
95
165
  /** Validate a register entry shape; throws with a clear message on a bad shape. */
@@ -167,14 +237,76 @@ function defaultRunScript(scriptsRoot, invoke, args) {
167
237
  const child = spawn(process.execPath, [resolved], {
168
238
  env: { ...process.env, TOOLFUNNEL_TOOL_ARGS: JSON.stringify(args ?? null) },
169
239
  stdio: ['ignore', 'pipe', 'pipe'],
240
+ windowsHide: true,
241
+ // POSIX process group so the timeout below can kill a whole tree; Windows uses taskkill /T.
242
+ detached: process.platform !== 'win32',
170
243
  });
244
+ reaper.track(child); // last-resort sweep if the gateway dies without its kill path
171
245
  let stdout = '';
172
246
  let stderr = '';
173
- child.stdout.on('data', (d) => { stdout += d.toString(); });
174
- child.stderr.on('data', (d) => { stderr += d.toString(); });
175
- child.on('error', reject);
247
+ let settled = false;
248
+ // BOUNDED + TIMED (0.7.0). The upstream call path has had per-method windows since 0.6.0
249
+ // (10 s control / 120 s payload) but the LOCAL executor had no clock and no output cap at all,
250
+ // and stdio serialises every message on one promise chain - so a single hanging local tool
251
+ // wedged the entire session: measured 2026-07-30, a 40 s tool left a following `ping` and
252
+ // `tools/list` unanswered for 12 s+, which a client using ping as its liveness probe reads as
253
+ // a dead gateway. Same shape as the fix in core/hook-runner.js.
254
+ const finish = (result) => {
255
+ if (settled) return;
256
+ settled = true;
257
+ clearTimeout(timer);
258
+ // Release the pipes and unref the handle - the ChildProcess itself keeps the event loop
259
+ // referenced until the child exits, so a tree that survives killTree would otherwise pin
260
+ // the process after we have already answered (same fix as core/hook-runner.js).
261
+ for (const s of [child.stdout, child.stderr]) {
262
+ try { if (s && typeof s.destroy === 'function') s.destroy(); } catch (_e) { /* ignore */ }
263
+ }
264
+ try { if (typeof child.unref === 'function') child.unref(); } catch (_e) { /* ignore */ }
265
+ reaper.untrack(child);
266
+ resolve(result);
267
+ };
268
+ const timer = setTimeout(() => {
269
+ killTree(child);
270
+ finish({
271
+ ok: false,
272
+ code: null,
273
+ stdout,
274
+ stderr: (stderr ? stderr + '\n' : '')
275
+ + `Registry: tool script timed out after ${TOOL_TIMEOUT_MS}ms and was terminated`,
276
+ timedOut: true,
277
+ });
278
+ }, TOOL_TIMEOUT_MS);
279
+ if (typeof timer.unref === 'function') timer.unref();
280
+ // setEncoding so a multi-byte UTF-8 sequence split across chunks decodes correctly (a raw
281
+ // d.toString() per chunk yields U+FFFD at the boundary), and count REAL bytes - String.length
282
+ // is UTF-16 code units, letting non-ASCII output run ~3x past a "byte" cap.
283
+ child.stdout.setEncoding('utf8');
284
+ child.stderr.setEncoding('utf8');
285
+ let outBytes = 0;
286
+ let errBytes = 0;
287
+ child.stdout.on('data', (d) => {
288
+ if (outBytes >= MAX_TOOL_OUTPUT_BYTES) return;
289
+ stdout += d;
290
+ outBytes += Buffer.byteLength(d, 'utf8');
291
+ if (outBytes >= MAX_TOOL_OUTPUT_BYTES) {
292
+ killTree(child);
293
+ finish({ ok: false, code: null, stdout, stderr: stderr + '\nRegistry: tool output exceeded the cap and was terminated', outputCapped: true });
294
+ }
295
+ });
296
+ child.stderr.on('data', (d) => {
297
+ if (errBytes >= MAX_TOOL_OUTPUT_BYTES) return;
298
+ stderr += d;
299
+ errBytes += Buffer.byteLength(d, 'utf8');
300
+ });
301
+ child.on('error', (err) => {
302
+ if (settled) return;
303
+ settled = true;
304
+ clearTimeout(timer);
305
+ reaper.untrack(child);
306
+ reject(err);
307
+ });
176
308
  child.on('close', (code) => {
177
- resolve({ ok: code === 0, code, stdout, stderr });
309
+ finish({ ok: code === 0, code, stdout, stderr });
178
310
  });
179
311
  });
180
312
  }
@@ -236,7 +368,7 @@ class Registry {
236
368
  return out;
237
369
  }
238
370
 
239
- /** Full instructions string for one tool. Throws on unknown id (per §5 contract). */
371
+ /** Full instructions string for one tool. Throws on unknown id. */
240
372
  instructions(id) {
241
373
  const t = this._byId.get(id);
242
374
  if (!t) throw new Error(`Registry.instructions: unknown tool id "${id}"`);
@@ -280,6 +412,16 @@ class Registry {
280
412
  }
281
413
  const merged = { ...existing, ...patch, id };
282
414
  validateEntry(merged);
415
+ // Rename-safety: the gate fires on `entry.name || id` for BOTH the direct and
416
+ // lean paths - a display-name edit renames that surface, so gate matchers must follow (or
417
+ // the rename refuses). The tools.state.json key must NOT: a LOCAL tool's overlay is keyed by
418
+ // its immutable register ID, not its display name (migrateState:false; see the namespace note
419
+ // in rename-migration.js). Runs BEFORE persist so a refusal leaves the register untouched.
420
+ const oldSurfaced = existing.name || id;
421
+ const newSurfaced = merged.name || id;
422
+ if (oldSurfaced !== newSurfaced) {
423
+ migrateForStoreFile(this._filePath, oldSurfaced, newSurfaced, { migrateState: false });
424
+ }
283
425
  this._byId.set(id, cloneEntry(merged));
284
426
  this._persist();
285
427
  return this.getEntry(id);
@@ -358,15 +500,21 @@ class Registry {
358
500
  fs.mkdirSync(scriptsRoot, { recursive: true });
359
501
  const dir = path.dirname(resolved);
360
502
  const base = path.basename(resolved);
503
+ sweepStaleTmp(dir, base);
361
504
  const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
362
- const fd = fs.openSync(tmp, 'w');
363
505
  try {
364
- fs.writeSync(fd, text, 0, 'utf8');
365
- fs.fsyncSync(fd);
366
- } finally {
367
- fs.closeSync(fd);
506
+ const fd = fs.openSync(tmp, 'w');
507
+ try {
508
+ fs.writeSync(fd, text, 0, 'utf8');
509
+ fs.fsyncSync(fd);
510
+ } finally {
511
+ fs.closeSync(fd);
512
+ }
513
+ fs.renameSync(tmp, resolved);
514
+ } catch (err) {
515
+ try { fs.unlinkSync(tmp); } catch (_e) { /* best-effort - the error below is the story */ }
516
+ throw err;
368
517
  }
369
- fs.renameSync(tmp, resolved);
370
518
  return resolved;
371
519
  }
372
520
 
@@ -376,7 +524,7 @@ class Registry {
376
524
  * Describe HOW to run a tool's invoke. Returns { type, run } where `run` is a
377
525
  * zero-arg function returning a Promise of the tool result. resolveExecution
378
526
  * performs NO gating - toolfunnel_run_tool / gated-run.js fires the PreToolUse hook
379
- * and only then calls `run()` (the architecture doc §2/§9). Keeping the gate
527
+ * and only then calls `run()`. Keeping the gate
380
528
  * out of here keeps the safety case in one auditable place.
381
529
  *
382
530
  * - script invoke: run() spawns node on the host-local script (via the injected
@@ -158,17 +158,23 @@
158
158
  var modeLabel = el('span', { class: 'toggle-label mode-label', text: t.mode || 'reference' });
159
159
  var modeGroup = el('div', { class: 'toggle-group mode' }, [modeSwitch, modeLabel]);
160
160
 
161
- // Pre / Post hook gates (POST /api/tools/hook).
161
+ // Pre / Post hook gates (POST /api/tools/hook). The switch reflects the tool's OWN literal
162
+ // entry (preOwn/postOwn), not "some broader hook fires" - a wildcard gate is not this
163
+ // switch's state, and rendering it as such made OFF toast a removal that never happened and
164
+ // then snap back on refresh. The server's stillGated/note is surfaced, and
165
+ // the panel re-fetches after every change instead of trusting the optimistic value.
162
166
  function hookSwitch(eventName, label, current) {
163
167
  return toggleGroup(label, 'hook', makeSwitch(current, true, function (input) {
164
168
  var want = input.checked;
165
169
  driveSwitch(input, '/api/tools/hook', { id: t.id, event: eventName, on: want },
166
170
  function (res) {
167
- if (eventName === 'PreToolUse') t.pre = want; else t.post = want;
168
- if (want && res.scriptPath) {
171
+ if (res && res.note && res.stillGated) {
172
+ toast(el('span', {}, [label + ' gate: ', res.note]), 'warn');
173
+ } else if (want && res.scriptPath) {
169
174
  toast(el('span', {}, [label + ' gate enabled for ', el('code', { text: t.id }), '. ', el('code', { text: res.scriptPath })]));
170
175
  } else if (want) { toast(label + ' gate enabled for ' + t.id, 'good'); }
171
176
  else { toast(label + ' gate removed for ' + t.id, 'good'); }
177
+ loadTools(); // re-render from the server's truth, never the optimistic flag
172
178
  },
173
179
  function () { input.checked = !want; });
174
180
  }));
@@ -217,7 +223,14 @@
217
223
 
218
224
  var controls = el('div', { class: 'row-controls' }, [
219
225
  modeGroup,
220
- el('div', { class: 'hook-toggles' }, [hookSwitch('PreToolUse', 'Pre', t.pre), hookSwitch('PostToolUse', 'Post', t.post)]),
226
+ el('div', { class: 'hook-toggles' }, [
227
+ hookSwitch('PreToolUse', 'Pre', t.preOwn === undefined ? t.pre : t.preOwn),
228
+ hookSwitch('PostToolUse', 'Post', t.postOwn === undefined ? t.post : t.postOwn),
229
+ // Covered by a broader hook the switch does not own: say so instead of faking the switch.
230
+ (t.pre && !t.preOwn) || (t.post && !t.postOwn)
231
+ ? el('span', { class: 'chip', title: 'A broader hook (wildcard or regex) also gates this tool. Manage it on the Hooks tab.', text: 'gated by broader hook' })
232
+ : null,
233
+ ]),
221
234
  toggleGroup('Enabled', 'enable', enableSwitch),
222
235
  toggleGroup('Hot', 'hot', hotSwitch),
223
236
  toggleGroup('Hidden', 'hidden', hiddenSwitch),