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.
- package/README.md +11 -7
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +97 -18
- package/docs/KNOWN_BUGS.md +71 -90
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +24 -10
- package/hooks/hooks.manifest.json +1 -1
- package/hooks/scripts/example-deny-dangerous.js +12 -4
- package/mcp/servers/mock-upstream/server.js +36 -6
- package/package.json +67 -65
- package/src/auth/config.js +2 -2
- package/src/auth/resource-server.js +12 -3
- package/src/core/child-reaper.js +61 -0
- package/src/core/events.js +7 -7
- package/src/core/hook-engine.js +85 -31
- package/src/core/hook-loader.js +46 -13
- package/src/core/hook-runner.js +258 -38
- package/src/core/logger.js +138 -12
- package/src/core/matcher.js +59 -10
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +1 -1
- package/src/extend/add-hook.md +2 -2
- package/src/extend/add-mcp.md +3 -6
- package/src/extend/configure.md +2 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/howto.js +3 -3
- package/src/extend/package.md +11 -8
- package/src/mcp/aggregator.js +480 -52
- package/src/mcp/expose-store.js +130 -13
- package/src/mcp/gated-run.js +26 -17
- package/src/mcp/http-transport.js +274 -22
- package/src/mcp/mcp-client.js +194 -31
- package/src/mcp/modern.js +44 -10
- package/src/mcp/server.js +513 -89
- package/src/tools/registry.js +172 -24
- package/src/ui/public/app.js +17 -4
- package/src/ui/server.js +297 -29
- package/tools/scripts/tf-hook-set.js +11 -3
- package/tools/tools.register.json +227 -227
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
package/src/tools/registry.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* registry.js - the dynamic, persisted Tool Register
|
|
4
|
+
* registry.js - the dynamic, persisted Tool Register.
|
|
5
5
|
*
|
|
6
|
-
* This is distinct from
|
|
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 (
|
|
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
|
|
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.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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()
|
|
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
|
package/src/ui/public/app.js
CHANGED
|
@@ -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 (
|
|
168
|
-
|
|
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' }, [
|
|
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),
|