toolfunnel 0.7.0 → 0.8.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 (73) hide show
  1. package/README.md +238 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +530 -451
  4. package/docs/KNOWN_BUGS.md +100 -80
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -84
  7. package/docs/packaging.md +116 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -39
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -147
  13. package/mcp/servers/mock-upstream/server.js +379 -380
  14. package/package.json +67 -67
  15. package/src/auth/config.js +172 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +358 -353
  18. package/src/core/atomic-write.js +97 -0
  19. package/src/core/child-reaper.js +61 -61
  20. package/src/core/config-home.js +226 -134
  21. package/src/core/events.js +105 -105
  22. package/src/core/hook-engine.js +304 -329
  23. package/src/core/hook-loader.js +766 -824
  24. package/src/core/hook-runner.js +615 -566
  25. package/src/core/logger.js +295 -297
  26. package/src/core/matcher.js +100 -100
  27. package/src/core/metrics.js +90 -75
  28. package/src/core/output-cap.js +31 -0
  29. package/src/core/rename-migration.js +178 -163
  30. package/src/core/requires.js +138 -138
  31. package/src/core/server-config.js +98 -98
  32. package/src/extend/add-hook.md +155 -152
  33. package/src/extend/add-mcp.md +119 -119
  34. package/src/extend/configure.md +88 -88
  35. package/src/extend/create-tool.md +168 -168
  36. package/src/extend/howto.js +102 -102
  37. package/src/extend/package.md +190 -190
  38. package/src/extend/wrap.md +99 -99
  39. package/src/mcp/aggregator.js +2248 -1674
  40. package/src/mcp/expose-store.js +755 -577
  41. package/src/mcp/gated-run.js +210 -210
  42. package/src/mcp/http-transport.js +1657 -1563
  43. package/src/mcp/mcp-client.js +1174 -1070
  44. package/src/mcp/modern.js +409 -406
  45. package/src/mcp/protocol.js +473 -468
  46. package/src/mcp/server.js +3317 -3112
  47. package/src/mcp/tool-approvals.js +396 -0
  48. package/src/tools/registry.js +607 -661
  49. package/src/tools/tool-state.js +170 -170
  50. package/src/ui/public/app.js +1246 -1187
  51. package/src/ui/public/index.html +286 -286
  52. package/src/ui/public/styles.css +535 -535
  53. package/src/ui/server.js +1742 -1616
  54. package/tools/scripts/base64.js +139 -139
  55. package/tools/scripts/danger.js +61 -61
  56. package/tools/scripts/echo.js +39 -39
  57. package/tools/scripts/hash.js +101 -101
  58. package/tools/scripts/json.js +109 -109
  59. package/tools/scripts/text-stats.js +131 -131
  60. package/tools/scripts/tf-accept-change.js +67 -0
  61. package/tools/scripts/tf-env.js +43 -43
  62. package/tools/scripts/tf-hook-add.js +151 -151
  63. package/tools/scripts/tf-hook-set.js +120 -120
  64. package/tools/scripts/tf-list.js +192 -192
  65. package/tools/scripts/tf-log.js +86 -86
  66. package/tools/scripts/tf-mcp-add.js +130 -117
  67. package/tools/scripts/tf-mcp-set.js +121 -121
  68. package/tools/scripts/tf-pack.js +237 -237
  69. package/tools/scripts/tf-tool-add.js +134 -134
  70. package/tools/scripts/tf-tool-set.js +142 -142
  71. package/tools/scripts/tf-wrap.js +103 -103
  72. package/tools/scripts/uuid.js +102 -102
  73. package/tools/tools.register.json +16 -5
@@ -1,661 +1,607 @@
1
- 'use strict';
2
-
3
- /**
4
- * registry.js - the dynamic, persisted Tool Register.
5
- *
6
- * This is distinct from the in-memory
7
- * catalogue of tools handed to a backend at connect time. THIS one is the
8
- * structured, on-disk register the lean meta-tools (toolfunnel_list_tools /
9
- * toolfunnel_tool_instructions / toolfunnel_run_tool) read every call - so register edits
10
- * are visible with no reconnect ("no restart for register changes").
11
- *
12
- * Register entry shape (tools.register.json -> { version, description, tools:[] }):
13
- * { id, name, summary, category, instructions, invoke, mode? }
14
- * invoke is one of:
15
- * { type: "script", path: "scripts/<file>" } // run a host-local script
16
- * { type: "shell", command: "..." } // run a shell command
17
- * mode (optional) is one of:
18
- * "gateway" - execute the invoke server-side via the gated run path (default for
19
- * a script/shell invoke; the original behaviour).
20
- * "reference" - do NOT execute; toolfunnel_run_tool returns the instructions so the
21
- * connected AI runs the action itself. invoke may be omitted.
22
- * When mode is absent it is inferred: script/shell invoke -> "gateway", else "reference".
23
- *
24
- * Responsibilities:
25
- * list({filter?, category?}) -> briefs only [{id,name,summary,category}]
26
- * instructions(id) -> the full instructions string (throws on unknown)
27
- * getEntry(id) -> the full entry (clone; throws on unknown)
28
- * add(entry) / update(id,patch) / remove(id) -> mutate + ATOMIC persist
29
- * resolveExecution(id, args) -> { type, run: () => Promise<result> }
30
- * describes HOW to run the invoke. It does NOT
31
- * gate - gating is gated-run.js's job.
32
- *
33
- * Pure-ish: the registry only touches its own JSON file + (for script invokes)
34
- * spawns node on a path resolved under this src/tools dir. Dependencies that a
35
- * test may want to stub (the executor) are injectable.
36
- *
37
- * CommonJS, Node built-ins only, atomic writes (temp + rename).
38
- */
39
-
40
- const fs = require('node:fs');
41
- const path = require('node:path');
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
- }
78
-
79
- const VALID_INVOKE_TYPES = new Set(['script', 'shell']);
80
-
81
- /**
82
- * Execution modes (optional per-entry "mode" field):
83
- * "gateway" - ToolFunnel EXECUTES the invoke server-side through the gated run path
84
- * (the original behaviour for every script/shell tool).
85
- * "reference" - ToolFunnel does NOT execute. toolfunnel_run_tool returns the tool's
86
- * instructions so the connected AI performs the action in ITS OWN
87
- * environment. A reference tool needs no invoke (nothing runs here).
88
- */
89
- const VALID_MODES = new Set(['reference', 'gateway']);
90
-
91
- /**
92
- * Resolve an entry's execution mode. An explicit, valid `mode` wins. When absent it is
93
- * INFERRED backward-compatibly: a script/shell invoke means "gateway" (run it here, as
94
- * every existing tool does); anything else means "reference" (there is nothing to run).
95
- * @param {object} entry a register entry
96
- * @returns {"gateway"|"reference"}
97
- */
98
- function resolveMode(entry) {
99
- if (entry && (entry.mode === 'reference' || entry.mode === 'gateway')) return entry.mode;
100
- const t = entry && entry.invoke && entry.invoke.type;
101
- return t === 'script' || t === 'shell' ? 'gateway' : 'reference';
102
- }
103
-
104
- /** Deep-ish clone for plain JSON entries (structuredClone may be absent on old node). */
105
- function cloneEntry(entry) {
106
- return JSON.parse(JSON.stringify(entry));
107
- }
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
-
135
- /**
136
- * Atomic write: serialise to a temp file in the SAME directory as the target
137
- * (so rename is atomic on the same filesystem/drive), fsync, then rename over
138
- * the target. A crash leaves either the old file or the new file, never a
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).
143
- */
144
- function atomicWriteJson(targetPath, obj) {
145
- const dir = path.dirname(targetPath);
146
- const base = path.basename(targetPath);
147
- sweepStaleTmp(dir, base);
148
- const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
149
- const data = JSON.stringify(obj, null, 2) + '\n';
150
- try {
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;
162
- }
163
- }
164
-
165
- /** Validate a register entry shape; throws with a clear message on a bad shape. */
166
- function validateEntry(entry, { requireId = true } = {}) {
167
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
168
- throw new Error('Registry: entry must be an object');
169
- }
170
- if (requireId && (typeof entry.id !== 'string' || entry.id.length === 0)) {
171
- throw new Error('Registry: entry.id must be a non-empty string');
172
- }
173
- if (typeof entry.name !== 'string' || entry.name.length === 0) {
174
- throw new Error(`Registry: entry.name must be a non-empty string (id=${entry.id})`);
175
- }
176
- // Optional execution mode. When present it must be one of the valid modes.
177
- if (entry.mode !== undefined && !VALID_MODES.has(entry.mode)) {
178
- throw new Error(
179
- `Registry: entry.mode must be one of ${[...VALID_MODES].join('|')} (id=${entry.id})`
180
- );
181
- }
182
- // A reference tool executes nothing server-side, so its invoke may be omitted. Only an
183
- // EXPLICIT mode:"reference" relaxes this - with mode absent the invoke is still required
184
- // (backward-compatible: every existing tool keeps its mandatory invoke).
185
- if (entry.mode === 'reference' && entry.invoke === undefined) {
186
- return entry;
187
- }
188
- const inv = entry.invoke;
189
- if (!inv || typeof inv !== 'object') {
190
- throw new Error(`Registry: entry.invoke is required (id=${entry.id})`);
191
- }
192
- if (!VALID_INVOKE_TYPES.has(inv.type)) {
193
- throw new Error(
194
- `Registry: entry.invoke.type must be one of ${[...VALID_INVOKE_TYPES].join('|')} (id=${entry.id})`
195
- );
196
- }
197
- if (inv.type === 'script' && (typeof inv.path !== 'string' || inv.path.length === 0)) {
198
- throw new Error(`Registry: script invoke needs a non-empty "path" (id=${entry.id})`);
199
- }
200
- if (inv.type === 'shell' && (typeof inv.command !== 'string' || inv.command.length === 0)) {
201
- throw new Error(`Registry: shell invoke needs a non-empty "command" (id=${entry.id})`);
202
- }
203
- // Optional MCP input schema. When present it must be a plain object - a hot-promoted tool
204
- // advertises it VERBATIM to MCP clients (tools/list), so a typo'd shape must fail loudly at
205
- // authoring time rather than silently degrade to the free-form fallback at surface time.
206
- if (
207
- entry.inputSchema !== undefined &&
208
- (typeof entry.inputSchema !== 'object' || entry.inputSchema === null || Array.isArray(entry.inputSchema))
209
- ) {
210
- throw new Error(`Registry: entry.inputSchema must be an object when present (id=${entry.id})`);
211
- }
212
- return entry;
213
- }
214
-
215
- /**
216
- * Default script executor: spawn `node <resolved script>` with the structured
217
- * args passed in env TOOLFUNNEL_TOOL_ARGS. Resolves with { ok, code, stdout, stderr }.
218
- * Never rejects on a non-zero exit - the caller (the gated runner / meta-tool)
219
- * decides how to surface a tool failure. It DOES reject only on spawn failure.
220
- *
221
- * Path safety: the script path must resolve INSIDE scriptsRoot (defense-in-depth
222
- * for the isolation rule - a register entry must not become a path-escape).
223
- */
224
- function defaultRunScript(scriptsRoot, invoke, args) {
225
- return new Promise((resolve, reject) => {
226
- const resolved = path.resolve(scriptsRoot, path.basename(invoke.path));
227
- // path.basename strips any directory traversal; confirm anyway.
228
- const rel = path.relative(scriptsRoot, resolved);
229
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
230
- reject(new Error(`Registry: script path escapes scripts root ("${invoke.path}")`));
231
- return;
232
- }
233
- if (!fs.existsSync(resolved)) {
234
- reject(new Error(`Registry: script not found ("${resolved}")`));
235
- return;
236
- }
237
- const child = spawn(process.execPath, [resolved], {
238
- env: { ...process.env, TOOLFUNNEL_TOOL_ARGS: JSON.stringify(args ?? null) },
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',
243
- });
244
- reaper.track(child); // last-resort sweep if the gateway dies without its kill path
245
- let stdout = '';
246
- let stderr = '';
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
- });
308
- child.on('close', (code) => {
309
- finish({ ok: code === 0, code, stdout, stderr });
310
- });
311
- });
312
- }
313
-
314
- class Registry {
315
- /**
316
- * @param {object} opts
317
- * @param {string} opts.filePath path to tools.register.json (source of truth on disk)
318
- * @param {object} opts.data parsed register data { version, description, tools }
319
- * @param {string} [opts.scriptsRoot] dir holding script-invoke targets (default: <fileDir>/scripts)
320
- * @param {Function} [opts.runScript] injectable (scriptsRoot, invoke, args) => Promise<result>
321
- */
322
- constructor({ filePath, data, scriptsRoot, runScript } = {}) {
323
- if (!filePath) throw new Error('Registry: filePath is required');
324
- this._filePath = filePath;
325
- this._scriptsRoot = scriptsRoot || path.join(path.dirname(filePath), 'scripts');
326
- this._runScript = runScript || defaultRunScript;
327
-
328
- const d = data && typeof data === 'object' ? data : {};
329
- this._version = typeof d.version === 'number' ? d.version : 1;
330
- this._description = typeof d.description === 'string' ? d.description : '';
331
- const tools = Array.isArray(d.tools) ? d.tools : [];
332
-
333
- // Index by id; reject duplicate ids at load (a duplicate id makes
334
- // instructions(id)/run(id) ambiguous).
335
- this._byId = new Map();
336
- for (const t of tools) {
337
- validateEntry(t);
338
- if (this._byId.has(t.id)) {
339
- throw new Error(`Registry: duplicate tool id "${t.id}" in ${filePath}`);
340
- }
341
- this._byId.set(t.id, cloneEntry(t));
342
- }
343
- }
344
-
345
- // ---- read API -----------------------------------------------------------
346
-
347
- /**
348
- * Briefs only - the surface toolfunnel_list_tools returns. Optional case-insensitive
349
- * substring `filter` (matched against id/name/summary) and exact `category`.
350
- * @returns {Array<{id,name,summary,category}>} in insertion order.
351
- */
352
- list({ filter, category } = {}) {
353
- const needle = typeof filter === 'string' && filter.length ? filter.toLowerCase() : null;
354
- const out = [];
355
- for (const t of this._byId.values()) {
356
- if (category && t.category !== category) continue;
357
- if (needle) {
358
- const hay = `${t.id} ${t.name} ${t.summary || ''}`.toLowerCase();
359
- if (!hay.includes(needle)) continue;
360
- }
361
- out.push({
362
- id: t.id,
363
- name: t.name,
364
- summary: t.summary || '',
365
- category: t.category || '',
366
- });
367
- }
368
- return out;
369
- }
370
-
371
- /** Full instructions string for one tool. Throws on unknown id. */
372
- instructions(id) {
373
- const t = this._byId.get(id);
374
- if (!t) throw new Error(`Registry.instructions: unknown tool id "${id}"`);
375
- return typeof t.instructions === 'string' ? t.instructions : '';
376
- }
377
-
378
- /** Full entry (cloned so callers can't mutate internal state). Throws on unknown id. */
379
- getEntry(id) {
380
- const t = this._byId.get(id);
381
- if (!t) throw new Error(`Registry.getEntry: unknown tool id "${id}"`);
382
- return cloneEntry(t);
383
- }
384
-
385
- /** @returns {boolean} */
386
- has(id) {
387
- return this._byId.has(id);
388
- }
389
-
390
- // ---- write API (atomic persist) ----------------------------------------
391
-
392
- /** Add a new entry. Throws if id missing/duplicate or shape invalid. Persists. */
393
- add(entry) {
394
- validateEntry(entry);
395
- if (this._byId.has(entry.id)) {
396
- throw new Error(`Registry.add: tool id "${entry.id}" already exists`);
397
- }
398
- this._byId.set(entry.id, cloneEntry(entry));
399
- this._persist();
400
- return this.getEntry(entry.id);
401
- }
402
-
403
- /**
404
- * Patch an existing entry. `id` cannot be changed via patch (it's the key).
405
- * Merges shallow over the existing entry, re-validates, persists.
406
- */
407
- update(id, patch) {
408
- const existing = this._byId.get(id);
409
- if (!existing) throw new Error(`Registry.update: unknown tool id "${id}"`);
410
- if (!patch || typeof patch !== 'object') {
411
- throw new Error('Registry.update: patch must be an object');
412
- }
413
- const merged = { ...existing, ...patch, id };
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
- }
425
- this._byId.set(id, cloneEntry(merged));
426
- this._persist();
427
- return this.getEntry(id);
428
- }
429
-
430
- /** Remove an entry. Throws on unknown id. Persists. */
431
- remove(id) {
432
- if (!this._byId.has(id)) throw new Error(`Registry.remove: unknown tool id "${id}"`);
433
- this._byId.delete(id);
434
- this._persist();
435
- return true;
436
- }
437
-
438
- /**
439
- * Re-read the register FILE and swap the in-memory index - the hot-reload seam. A running
440
- * gateway holds ONE Registry instance (captured by the protocol adapter at build time), while
441
- * tf_tool_add / the UI / a hand edit mutate the FILE from another process - without this, those
442
- * edits are invisible until restart. LAST-GOOD semantics: the replacement index is built and
443
- * fully validated FIRST; any read/parse/validation failure (including a mid-write partial file)
444
- * leaves the current index untouched and returns false. NEVER throws.
445
- * @returns {boolean} true when the on-disk register replaced the in-memory index
446
- */
447
- reload() {
448
- let data;
449
- try {
450
- data = JSON.parse(fs.readFileSync(this._filePath, 'utf8'));
451
- } catch (_e) {
452
- return false; // unreadable / mid-write / bad JSON -> keep last-good
453
- }
454
- if (!data || typeof data !== 'object' || Array.isArray(data)) return false;
455
- const tools = Array.isArray(data.tools) ? data.tools : [];
456
- const next = new Map();
457
- try {
458
- for (const t of tools) {
459
- validateEntry(t);
460
- if (next.has(t.id)) throw new Error(`duplicate tool id "${t.id}"`);
461
- next.set(t.id, cloneEntry(t));
462
- }
463
- } catch (_e) {
464
- return false; // an invalid entry -> keep last-good
465
- }
466
- if (typeof data.version === 'number') this._version = data.version;
467
- if (typeof data.description === 'string') this._description = data.description;
468
- this._byId = next;
469
- return true;
470
- }
471
-
472
- /**
473
- * Write a host-local script's text under this scriptsRoot, atomically.
474
- *
475
- * Resolves path.basename(invokePath) under scriptsRoot (basename strips any
476
- * directory traversal) and GUARDS that the resolved path stays inside
477
- * scriptsRoot - the same path-escape defence as defaultRunScript, so a
478
- * tf_tool_add(scriptText) can never write outside the isolation root. Writes
479
- * `text` via a temp file in the same dir + fsync + rename (mirror of
480
- * atomicWriteJson): a crash leaves either no file or the complete file.
481
- *
482
- * @param {string} invokePath e.g. "scripts/foo.js" - only the basename is honoured
483
- * @param {string} text full file contents to write
484
- * @returns {string} the resolved absolute path that was written
485
- */
486
- writeScript(invokePath, text) {
487
- if (typeof invokePath !== 'string' || invokePath.length === 0) {
488
- throw new Error('Registry.writeScript: invokePath (non-empty string) is required');
489
- }
490
- if (typeof text !== 'string') {
491
- throw new Error('Registry.writeScript: text must be a string');
492
- }
493
- const scriptsRoot = this._scriptsRoot;
494
- const resolved = path.resolve(scriptsRoot, path.basename(invokePath));
495
- // path.basename strips any directory traversal; confirm anyway.
496
- const rel = path.relative(scriptsRoot, resolved);
497
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
498
- throw new Error(`Registry.writeScript: script path escapes scripts root ("${invokePath}")`);
499
- }
500
- fs.mkdirSync(scriptsRoot, { recursive: true });
501
- const dir = path.dirname(resolved);
502
- const base = path.basename(resolved);
503
- sweepStaleTmp(dir, base);
504
- const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
505
- try {
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;
517
- }
518
- return resolved;
519
- }
520
-
521
- // ---- execution description (does NOT gate) ------------------------------
522
-
523
- /**
524
- * Describe HOW to run a tool's invoke. Returns { type, run } where `run` is a
525
- * zero-arg function returning a Promise of the tool result. resolveExecution
526
- * performs NO gating - toolfunnel_run_tool / gated-run.js fires the PreToolUse hook
527
- * and only then calls `run()`. Keeping the gate
528
- * out of here keeps the safety case in one auditable place.
529
- *
530
- * - script invoke: run() spawns node on the host-local script (via the injected
531
- * runScript), passing args in TOOLFUNNEL_TOOL_ARGS.
532
- * - shell invoke: run() does NOT execute here. It returns a descriptor
533
- * { type:'shell', command, args } so the gated runner owns the actual shell
534
- * spawn (the broadest power) behind the gate. This keeps arbitrary shell
535
- * execution out of the un-gated register module by design.
536
- *
537
- * @param {string} id
538
- * @param {any} args structured args forwarded to the tool
539
- * @returns {{ type: string, run: () => Promise<any> }}
540
- */
541
- resolveExecution(id, args) {
542
- const entry = this._byId.get(id);
543
- if (!entry) throw new Error(`Registry.resolveExecution: unknown tool id "${id}"`);
544
- const mode = resolveMode(entry);
545
-
546
- // ---- reference mode: nothing runs here. ----------------------------------
547
- // The connected AI performs the action in its own environment. We hand back the
548
- // tool's instructions (no `run`) so the run-path takes the no-spawn branch - a
549
- // reference tool never spawns server-side. The instruction HANDOFF is still gated
550
- // in protocol.runTool (a PreToolUse deny withholds the instructions); it's execution,
551
- // not the gate, that's absent here.
552
- if (mode === 'reference') {
553
- return {
554
- type: 'reference',
555
- mode: 'reference',
556
- instructions: typeof entry.instructions === 'string' ? entry.instructions : '',
557
- };
558
- }
559
-
560
- const invoke = entry.invoke;
561
-
562
- if (invoke.type === 'script') {
563
- const scriptsRoot = this._scriptsRoot;
564
- const runScript = this._runScript;
565
- return {
566
- type: 'script',
567
- mode: 'gateway',
568
- run: () => runScript(scriptsRoot, invoke, args),
569
- };
570
- }
571
-
572
- if (invoke.type === 'shell') {
573
- // Deliberately do NOT spawn a shell here. Hand the descriptor to the
574
- // gated runner, which executes shell only after PreToolUse allows it.
575
- return {
576
- type: 'shell',
577
- mode: 'gateway',
578
- run: async () => ({
579
- deferred: true,
580
- type: 'shell',
581
- command: invoke.command,
582
- args: args ?? null,
583
- }),
584
- };
585
- }
586
-
587
- // validateEntry guards the load path, but guard here too for late mutation.
588
- throw new Error(`Registry.resolveExecution: unsupported invoke.type for "${id}"`);
589
- }
590
-
591
- /**
592
- * The resolved execution mode for a tool ("gateway" | "reference"). Exposed so callers
593
- * (UI, tests) can ask without resolving a full executor. Throws on unknown id.
594
- * @param {string} id
595
- * @returns {"gateway"|"reference"}
596
- */
597
- mode(id) {
598
- const entry = this._byId.get(id);
599
- if (!entry) throw new Error(`Registry.mode: unknown tool id "${id}"`);
600
- return resolveMode(entry);
601
- }
602
-
603
- // ---- internals ----------------------------------------------------------
604
-
605
- /** Serialise current state back to the register file atomically. */
606
- _persist() {
607
- const obj = {
608
- version: this._version,
609
- description: this._description,
610
- tools: [...this._byId.values()],
611
- };
612
- atomicWriteJson(this._filePath, obj);
613
- }
614
-
615
- /** @returns {number} */
616
- get size() {
617
- return this._byId.size;
618
- }
619
- }
620
-
621
- /**
622
- * loadRegistry(path) -> Registry
623
- *
624
- * Reads + parses the register JSON at `filePath` and returns a Registry bound to
625
- * it. Throws a clear error on a missing/malformed file or a bad entry shape so
626
- * misconfiguration is caught at load time, not at model-call time.
627
- *
628
- * @param {string} filePath
629
- * @param {object} [opts] forwarded to the Registry ctor (scriptsRoot, runScript)
630
- * @returns {Registry}
631
- */
632
- function loadRegistry(filePath, opts = {}) {
633
- if (typeof filePath !== 'string' || filePath.length === 0) {
634
- throw new Error('loadRegistry: filePath (non-empty string) is required');
635
- }
636
- let raw;
637
- try {
638
- raw = fs.readFileSync(filePath, 'utf8');
639
- } catch (err) {
640
- throw new Error(`loadRegistry: cannot read register at "${filePath}": ${err.message}`);
641
- }
642
- let data;
643
- try {
644
- data = JSON.parse(raw);
645
- } catch (err) {
646
- throw new Error(`loadRegistry: register at "${filePath}" is not valid JSON: ${err.message}`);
647
- }
648
- return new Registry({ filePath, data, ...opts });
649
- }
650
-
651
- module.exports = {
652
- Registry,
653
- loadRegistry,
654
- // exported for unit tests / the gated runner
655
- atomicWriteJson,
656
- validateEntry,
657
- defaultRunScript,
658
- resolveMode,
659
- VALID_INVOKE_TYPES,
660
- VALID_MODES,
661
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * registry.js - the dynamic, persisted Tool Register.
5
+ *
6
+ * This is distinct from the in-memory
7
+ * catalogue of tools handed to a backend at connect time. THIS one is the
8
+ * structured, on-disk register the lean meta-tools (toolfunnel_list_tools /
9
+ * toolfunnel_tool_instructions / toolfunnel_run_tool) read every call - so register edits
10
+ * are visible with no reconnect ("no restart for register changes").
11
+ *
12
+ * Register entry shape (tools.register.json -> { version, description, tools:[] }):
13
+ * { id, name, summary, category, instructions, invoke, mode? }
14
+ * invoke is one of:
15
+ * { type: "script", path: "scripts/<file>" } // run a host-local script
16
+ * { type: "shell", command: "..." } // run a shell command
17
+ * mode (optional) is one of:
18
+ * "gateway" - execute the invoke server-side via the gated run path (default for
19
+ * a script/shell invoke; the original behaviour).
20
+ * "reference" - do NOT execute; toolfunnel_run_tool returns the instructions so the
21
+ * connected AI runs the action itself. invoke may be omitted.
22
+ * When mode is absent it is inferred: script/shell invoke -> "gateway", else "reference".
23
+ *
24
+ * Responsibilities:
25
+ * list({filter?, category?}) -> briefs only [{id,name,summary,category}]
26
+ * instructions(id) -> the full instructions string (throws on unknown)
27
+ * getEntry(id) -> the full entry (clone; throws on unknown)
28
+ * add(entry) / update(id,patch) / remove(id) -> mutate + ATOMIC persist
29
+ * resolveExecution(id, args) -> { type, run: () => Promise<result> }
30
+ * describes HOW to run the invoke. It does NOT
31
+ * gate - gating is gated-run.js's job.
32
+ *
33
+ * Pure-ish: the registry only touches its own JSON file + (for script invokes)
34
+ * spawns node on a path resolved under this src/tools dir. Dependencies that a
35
+ * test may want to stub (the executor) are injectable.
36
+ *
37
+ * CommonJS, Node built-ins only, atomic writes (temp + rename).
38
+ */
39
+
40
+ const fs = require('node:fs');
41
+ const path = require('node:path');
42
+ const { spawn, execFile } = require('node:child_process');
43
+ const reaper = require('../core/child-reaper');
44
+ const { takeWithinCap } = require('../core/output-cap');
45
+ const { migrateForStoreFile, snapshotForStoreFile, restoreSnapshot } = require('../core/rename-migration');
46
+
47
+ // LOCAL tool execution bounds. The upstream path has per-method timeouts; stdio serialises on one
48
+ // promise chain, so a local tool with no clock would starve every later request (including
49
+ // `ping`, which clients use as a liveness probe). 120 s
50
+ // matches the upstream PAYLOAD window so a slow-but-alive local tool behaves like a slow upstream.
51
+ // Test seam: the suite proves the timeout/cap BEHAVIOUR (not just these names) by shrinking the
52
+ // windows via env; production never sets them.
53
+ const TOOL_TIMEOUT_MS = Number(process.env.TOOLFUNNEL_TOOL_TIMEOUT_MS) > 0
54
+ ? Number(process.env.TOOLFUNNEL_TOOL_TIMEOUT_MS) : 120000;
55
+ const MAX_TOOL_OUTPUT_BYTES = Number(process.env.TOOLFUNNEL_TOOL_OUTPUT_CAP) > 0
56
+ ? Number(process.env.TOOLFUNNEL_TOOL_OUTPUT_CAP) : 4 * 1024 * 1024;
57
+
58
+ /**
59
+ * Kill a spawned child AND its descendants, by each platform's own mechanism: taskkill /T on
60
+ * Windows, the process group on POSIX (the spawn sets `detached` there so the child leads one).
61
+ * Killing only the direct child orphans grandchildren, which then hold the stdio pipes open.
62
+ * Best-effort and never throws.
63
+ * @param {import('node:child_process').ChildProcess} child
64
+ */
65
+ function killTree(child) {
66
+ if (!child) return;
67
+ try {
68
+ if (child.pid != null && process.platform === 'win32') {
69
+ execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
70
+ } else if (child.pid != null) {
71
+ try { process.kill(-child.pid, 'SIGKILL'); } catch (_eg) { child.kill('SIGKILL'); }
72
+ } else {
73
+ child.kill('SIGKILL');
74
+ }
75
+ } catch (_e) {
76
+ try { child.kill(); } catch (_e2) { /* nothing more we can do */ }
77
+ }
78
+ }
79
+
80
+ const VALID_INVOKE_TYPES = new Set(['script', 'shell']);
81
+
82
+ /**
83
+ * Execution modes (optional per-entry "mode" field):
84
+ * "gateway" - ToolFunnel EXECUTES the invoke server-side through the gated run path
85
+ * (the original behaviour for every script/shell tool).
86
+ * "reference" - ToolFunnel does NOT execute. toolfunnel_run_tool returns the tool's
87
+ * instructions so the connected AI performs the action in ITS OWN
88
+ * environment. A reference tool needs no invoke (nothing runs here).
89
+ */
90
+ const VALID_MODES = new Set(['reference', 'gateway']);
91
+
92
+ /**
93
+ * Resolve an entry's execution mode. An explicit, valid `mode` wins. When absent it is
94
+ * INFERRED backward-compatibly: a script/shell invoke means "gateway" (run it here, as
95
+ * every existing tool does); anything else means "reference" (there is nothing to run).
96
+ * @param {object} entry a register entry
97
+ * @returns {"gateway"|"reference"}
98
+ */
99
+ function resolveMode(entry) {
100
+ if (entry && (entry.mode === 'reference' || entry.mode === 'gateway')) return entry.mode;
101
+ const t = entry && entry.invoke && entry.invoke.type;
102
+ return t === 'script' || t === 'shell' ? 'gateway' : 'reference';
103
+ }
104
+
105
+ /** Deep-ish clone for plain JSON entries (structuredClone may be absent on old node). */
106
+ function cloneEntry(entry) {
107
+ return JSON.parse(JSON.stringify(entry));
108
+ }
109
+
110
+ // THE atomic writer (temp + fsync + rename, failure-path unlink, stale sweep) - shared by every store.
111
+ // Re-exported below for the callers that historically imported it from here.
112
+ const { atomicWriteJson, atomicWriteText } = require('../core/atomic-write');
113
+
114
+ /** Validate a register entry shape; throws with a clear message on a bad shape. */
115
+ function validateEntry(entry, { requireId = true } = {}) {
116
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
117
+ throw new Error('Registry: entry must be an object');
118
+ }
119
+ if (requireId && (typeof entry.id !== 'string' || entry.id.length === 0)) {
120
+ throw new Error('Registry: entry.id must be a non-empty string');
121
+ }
122
+ if (typeof entry.name !== 'string' || entry.name.length === 0) {
123
+ throw new Error(`Registry: entry.name must be a non-empty string (id=${entry.id})`);
124
+ }
125
+ // Optional execution mode. When present it must be one of the valid modes.
126
+ if (entry.mode !== undefined && !VALID_MODES.has(entry.mode)) {
127
+ throw new Error(
128
+ `Registry: entry.mode must be one of ${[...VALID_MODES].join('|')} (id=${entry.id})`
129
+ );
130
+ }
131
+ // A reference tool executes nothing server-side, so its invoke may be omitted. Only an
132
+ // EXPLICIT mode:"reference" relaxes this - with mode absent the invoke is still required
133
+ // (backward-compatible: every existing tool keeps its mandatory invoke).
134
+ if (entry.mode === 'reference' && entry.invoke === undefined) {
135
+ return entry;
136
+ }
137
+ const inv = entry.invoke;
138
+ if (!inv || typeof inv !== 'object') {
139
+ throw new Error(`Registry: entry.invoke is required (id=${entry.id})`);
140
+ }
141
+ if (!VALID_INVOKE_TYPES.has(inv.type)) {
142
+ throw new Error(
143
+ `Registry: entry.invoke.type must be one of ${[...VALID_INVOKE_TYPES].join('|')} (id=${entry.id})`
144
+ );
145
+ }
146
+ if (inv.type === 'script' && (typeof inv.path !== 'string' || inv.path.length === 0)) {
147
+ throw new Error(`Registry: script invoke needs a non-empty "path" (id=${entry.id})`);
148
+ }
149
+ if (inv.type === 'shell' && (typeof inv.command !== 'string' || inv.command.length === 0)) {
150
+ throw new Error(`Registry: shell invoke needs a non-empty "command" (id=${entry.id})`);
151
+ }
152
+ // Optional MCP input schema. When present it must be a plain object - a hot-promoted tool
153
+ // advertises it VERBATIM to MCP clients (tools/list), so a typo'd shape must fail loudly at
154
+ // authoring time rather than silently degrade to the free-form fallback at surface time.
155
+ if (
156
+ entry.inputSchema !== undefined &&
157
+ (typeof entry.inputSchema !== 'object' || entry.inputSchema === null || Array.isArray(entry.inputSchema))
158
+ ) {
159
+ throw new Error(`Registry: entry.inputSchema must be an object when present (id=${entry.id})`);
160
+ }
161
+ return entry;
162
+ }
163
+
164
+ /**
165
+ * Default script executor: spawn `node <resolved script>` with the structured
166
+ * args passed in env TOOLFUNNEL_TOOL_ARGS. Resolves with { ok, code, stdout, stderr }.
167
+ * Never rejects on a non-zero exit - the caller (the gated runner / meta-tool)
168
+ * decides how to surface a tool failure. It DOES reject only on spawn failure.
169
+ *
170
+ * Path safety: the script path must resolve INSIDE scriptsRoot (defense-in-depth
171
+ * for the isolation rule - a register entry must not become a path-escape).
172
+ */
173
+ function defaultRunScript(scriptsRoot, invoke, args) {
174
+ return new Promise((resolve, reject) => {
175
+ const resolved = path.resolve(scriptsRoot, path.basename(invoke.path));
176
+ // path.basename strips any directory traversal; confirm anyway.
177
+ const rel = path.relative(scriptsRoot, resolved);
178
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
179
+ reject(new Error(`Registry: script path escapes scripts root ("${invoke.path}")`));
180
+ return;
181
+ }
182
+ if (!fs.existsSync(resolved)) {
183
+ reject(new Error(`Registry: script not found ("${resolved}")`));
184
+ return;
185
+ }
186
+ const child = spawn(process.execPath, [resolved], {
187
+ env: { ...process.env, TOOLFUNNEL_TOOL_ARGS: JSON.stringify(args ?? null) },
188
+ stdio: ['ignore', 'pipe', 'pipe'],
189
+ windowsHide: true,
190
+ // POSIX process group so the timeout below can kill a whole tree; Windows uses taskkill /T.
191
+ detached: process.platform !== 'win32',
192
+ });
193
+ reaper.track(child); // last-resort sweep if the gateway dies without its kill path
194
+ let stdout = '';
195
+ let stderr = '';
196
+ let settled = false;
197
+ // BOUNDED + TIMED, like the upstream call path's per-method windows (10 s control / 120 s
198
+ // payload). stdio serialises every message on one promise chain, so a single hanging local
199
+ // tool would wedge the entire session: a following `ping` goes unanswered, which a client
200
+ // using ping as its liveness probe reads as a dead gateway. Same shape as the timeout in
201
+ // core/hook-runner.js.
202
+ const finish = (result) => {
203
+ if (settled) return;
204
+ settled = true;
205
+ clearTimeout(timer);
206
+ // Release the pipes and unref the handle - the ChildProcess itself keeps the event loop
207
+ // referenced until the child exits, so a tree that survives killTree would otherwise pin
208
+ // the process after we have already answered (same fix as core/hook-runner.js).
209
+ for (const s of [child.stdout, child.stderr]) {
210
+ try { if (s && typeof s.destroy === 'function') s.destroy(); } catch (_e) { /* ignore */ }
211
+ }
212
+ try { if (typeof child.unref === 'function') child.unref(); } catch (_e) { /* ignore */ }
213
+ reaper.untrack(child);
214
+ resolve(result);
215
+ };
216
+ const timer = setTimeout(() => {
217
+ killTree(child);
218
+ finish({
219
+ ok: false,
220
+ code: null,
221
+ stdout,
222
+ stderr: (stderr ? stderr + '\n' : '')
223
+ + `Registry: tool script timed out after ${TOOL_TIMEOUT_MS}ms and was terminated`,
224
+ timedOut: true,
225
+ });
226
+ }, TOOL_TIMEOUT_MS);
227
+ if (typeof timer.unref === 'function') timer.unref();
228
+ // setEncoding so a multi-byte UTF-8 sequence split across chunks decodes correctly (a raw
229
+ // d.toString() per chunk yields U+FFFD at the boundary), and count REAL bytes - String.length
230
+ // is UTF-16 code units, letting non-ASCII output run ~3x past a "byte" cap.
231
+ child.stdout.setEncoding('utf8');
232
+ child.stderr.setEncoding('utf8');
233
+ let outBytes = 0;
234
+ let errBytes = 0;
235
+ child.stdout.on('data', (d) => {
236
+ if (outBytes >= MAX_TOOL_OUTPUT_BYTES) return;
237
+ const kept = takeWithinCap(d, outBytes, MAX_TOOL_OUTPUT_BYTES);
238
+ stdout += kept;
239
+ outBytes += Buffer.byteLength(kept, 'utf8');
240
+ if (kept.length < d.length) outBytes = MAX_TOOL_OUTPUT_BYTES; // cut at a character: the cap is reached
241
+ if (outBytes >= MAX_TOOL_OUTPUT_BYTES) {
242
+ killTree(child);
243
+ finish({ ok: false, code: null, stdout, stderr: stderr + '\nRegistry: tool output exceeded the cap and was terminated', outputCapped: true });
244
+ }
245
+ });
246
+ child.stderr.on('data', (d) => {
247
+ if (errBytes >= MAX_TOOL_OUTPUT_BYTES) return;
248
+ const kept = takeWithinCap(d, errBytes, MAX_TOOL_OUTPUT_BYTES);
249
+ stderr += kept;
250
+ errBytes = kept.length < d.length ? MAX_TOOL_OUTPUT_BYTES : errBytes + Buffer.byteLength(kept, 'utf8');
251
+ if (errBytes >= MAX_TOOL_OUTPUT_BYTES) stderr += '\n[stderr truncated at cap]';
252
+ });
253
+ child.on('error', (err) => {
254
+ if (settled) return;
255
+ settled = true;
256
+ clearTimeout(timer);
257
+ reaper.untrack(child);
258
+ reject(err);
259
+ });
260
+ child.on('close', (code) => {
261
+ finish({ ok: code === 0, code, stdout, stderr });
262
+ });
263
+ });
264
+ }
265
+
266
+ class Registry {
267
+ /**
268
+ * @param {object} opts
269
+ * @param {string} opts.filePath path to tools.register.json (source of truth on disk)
270
+ * @param {object} opts.data parsed register data { version, description, tools }
271
+ * @param {string} [opts.scriptsRoot] dir holding script-invoke targets (default: <fileDir>/scripts)
272
+ * @param {Function} [opts.runScript] injectable (scriptsRoot, invoke, args) => Promise<result>
273
+ */
274
+ constructor({ filePath, data, scriptsRoot, runScript } = {}) {
275
+ if (!filePath) throw new Error('Registry: filePath is required');
276
+ this._filePath = filePath;
277
+ this._scriptsRoot = scriptsRoot || path.join(path.dirname(filePath), 'scripts');
278
+ this._runScript = runScript || defaultRunScript;
279
+
280
+ const d = data && typeof data === 'object' ? data : {};
281
+ this._version = typeof d.version === 'number' ? d.version : 1;
282
+ this._description = typeof d.description === 'string' ? d.description : '';
283
+ const tools = Array.isArray(d.tools) ? d.tools : [];
284
+
285
+ // Index by id; reject duplicate ids at load (a duplicate id makes
286
+ // instructions(id)/run(id) ambiguous).
287
+ this._byId = new Map();
288
+ for (const t of tools) {
289
+ validateEntry(t);
290
+ if (this._byId.has(t.id)) {
291
+ throw new Error(`Registry: duplicate tool id "${t.id}" in ${filePath}`);
292
+ }
293
+ this._byId.set(t.id, cloneEntry(t));
294
+ }
295
+ }
296
+
297
+ // ---- read API -----------------------------------------------------------
298
+
299
+ /**
300
+ * Briefs only - the surface toolfunnel_list_tools returns. Optional case-insensitive
301
+ * substring `filter` (matched against id/name/summary) and exact `category`.
302
+ * @returns {Array<{id,name,summary,category}>} in insertion order.
303
+ */
304
+ list({ filter, category } = {}) {
305
+ const needle = typeof filter === 'string' && filter.length ? filter.toLowerCase() : null;
306
+ const out = [];
307
+ for (const t of this._byId.values()) {
308
+ if (category && t.category !== category) continue;
309
+ if (needle) {
310
+ const hay = `${t.id} ${t.name} ${t.summary || ''}`.toLowerCase();
311
+ if (!hay.includes(needle)) continue;
312
+ }
313
+ out.push({
314
+ id: t.id,
315
+ name: t.name,
316
+ summary: t.summary || '',
317
+ category: t.category || '',
318
+ });
319
+ }
320
+ return out;
321
+ }
322
+
323
+ /** Full instructions string for one tool. Throws on unknown id. */
324
+ instructions(id) {
325
+ const t = this._byId.get(id);
326
+ if (!t) throw new Error(`Registry.instructions: unknown tool id "${id}"`);
327
+ return typeof t.instructions === 'string' ? t.instructions : '';
328
+ }
329
+
330
+ /** Full entry (cloned so callers can't mutate internal state). Throws on unknown id. */
331
+ getEntry(id) {
332
+ const t = this._byId.get(id);
333
+ if (!t) throw new Error(`Registry.getEntry: unknown tool id "${id}"`);
334
+ return cloneEntry(t);
335
+ }
336
+
337
+ /** @returns {boolean} */
338
+ has(id) {
339
+ return this._byId.has(id);
340
+ }
341
+
342
+ // ---- write API (atomic persist) ----------------------------------------
343
+
344
+ /** Add a new entry. Throws if id missing/duplicate or shape invalid. Persists. */
345
+ add(entry) {
346
+ validateEntry(entry);
347
+ if (this._byId.has(entry.id)) {
348
+ throw new Error(`Registry.add: tool id "${entry.id}" already exists`);
349
+ }
350
+ this._byId.set(entry.id, cloneEntry(entry));
351
+ this._persist();
352
+ return this.getEntry(entry.id);
353
+ }
354
+
355
+ /**
356
+ * Patch an existing entry. `id` cannot be changed via patch (it's the key).
357
+ * Merges shallow over the existing entry, re-validates, persists.
358
+ */
359
+ update(id, patch) {
360
+ const existing = this._byId.get(id);
361
+ if (!existing) throw new Error(`Registry.update: unknown tool id "${id}"`);
362
+ if (!patch || typeof patch !== 'object') {
363
+ throw new Error('Registry.update: patch must be an object');
364
+ }
365
+ const merged = { ...existing, ...patch, id };
366
+ validateEntry(merged);
367
+ // Rename-safety: the gate fires on `entry.name || id` for BOTH the direct and
368
+ // lean paths - a display-name edit renames that surface, so gate matchers must follow (or
369
+ // the rename refuses). The tools.state.json key must NOT: a LOCAL tool's overlay is keyed by
370
+ // its immutable register ID, not its display name (migrateState:false; see the namespace note
371
+ // in rename-migration.js). Runs BEFORE persist so a refusal leaves the register untouched.
372
+ const oldSurfaced = existing.name || id;
373
+ const newSurfaced = merged.name || id;
374
+ const snap = oldSurfaced !== newSurfaced ? snapshotForStoreFile(this._filePath) : null;
375
+ if (oldSurfaced !== newSurfaced) {
376
+ migrateForStoreFile(this._filePath, oldSurfaced, newSurfaced, { migrateState: false });
377
+ }
378
+ this._byId.set(id, cloneEntry(merged));
379
+ try {
380
+ this._persist();
381
+ } catch (err) {
382
+ // The other half of "runs BEFORE persist": a persist that fails AFTER a successful migration
383
+ // must not leave the manifest naming a surface the register does not. Unwind memory and
384
+ // migrate back (best-effort), then rethrow - a failed rename is a no-op on disk.
385
+ this._byId.set(id, existing);
386
+ if (snap) restoreSnapshot(snap); // byte-for-byte; a reverse migration would re-validate and can refuse
387
+ throw err;
388
+ }
389
+ return this.getEntry(id);
390
+ }
391
+
392
+ /** Remove an entry. Throws on unknown id. Persists. */
393
+ remove(id) {
394
+ if (!this._byId.has(id)) throw new Error(`Registry.remove: unknown tool id "${id}"`);
395
+ this._byId.delete(id);
396
+ this._persist();
397
+ return true;
398
+ }
399
+
400
+ /**
401
+ * Re-read the register FILE and swap the in-memory index - the hot-reload seam. A running
402
+ * gateway holds ONE Registry instance (captured by the protocol adapter at build time), while
403
+ * tf_tool_add / the UI / a hand edit mutate the FILE from another process - without this, those
404
+ * edits are invisible until restart. LAST-GOOD semantics: the replacement index is built and
405
+ * fully validated FIRST; any read/parse/validation failure (including a mid-write partial file)
406
+ * leaves the current index untouched and returns false. NEVER throws.
407
+ * @returns {boolean} true when the on-disk register replaced the in-memory index
408
+ */
409
+ reload() {
410
+ let data;
411
+ try {
412
+ data = JSON.parse(fs.readFileSync(this._filePath, 'utf8'));
413
+ } catch (_e) {
414
+ return false; // unreadable / mid-write / bad JSON -> keep last-good
415
+ }
416
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return false;
417
+ const tools = Array.isArray(data.tools) ? data.tools : [];
418
+ const next = new Map();
419
+ try {
420
+ for (const t of tools) {
421
+ validateEntry(t);
422
+ if (next.has(t.id)) throw new Error(`duplicate tool id "${t.id}"`);
423
+ next.set(t.id, cloneEntry(t));
424
+ }
425
+ } catch (_e) {
426
+ return false; // an invalid entry -> keep last-good
427
+ }
428
+ if (typeof data.version === 'number') this._version = data.version;
429
+ if (typeof data.description === 'string') this._description = data.description;
430
+ this._byId = next;
431
+ return true;
432
+ }
433
+
434
+ /**
435
+ * Write a host-local script's text under this scriptsRoot, atomically.
436
+ *
437
+ * Resolves path.basename(invokePath) under scriptsRoot (basename strips any
438
+ * directory traversal) and GUARDS that the resolved path stays inside
439
+ * scriptsRoot - the same path-escape defence as defaultRunScript, so a
440
+ * tf_tool_add(scriptText) can never write outside the isolation root. Writes
441
+ * `text` via a temp file in the same dir + fsync + rename (mirror of
442
+ * atomicWriteJson): a crash leaves either no file or the complete file.
443
+ *
444
+ * @param {string} invokePath e.g. "scripts/foo.js" - only the basename is honoured
445
+ * @param {string} text full file contents to write
446
+ * @returns {string} the resolved absolute path that was written
447
+ */
448
+ writeScript(invokePath, text) {
449
+ if (typeof invokePath !== 'string' || invokePath.length === 0) {
450
+ throw new Error('Registry.writeScript: invokePath (non-empty string) is required');
451
+ }
452
+ if (typeof text !== 'string') {
453
+ throw new Error('Registry.writeScript: text must be a string');
454
+ }
455
+ const scriptsRoot = this._scriptsRoot;
456
+ const resolved = path.resolve(scriptsRoot, path.basename(invokePath));
457
+ // path.basename strips any directory traversal; confirm anyway.
458
+ const rel = path.relative(scriptsRoot, resolved);
459
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
460
+ throw new Error(`Registry.writeScript: script path escapes scripts root ("${invokePath}")`);
461
+ }
462
+ fs.mkdirSync(scriptsRoot, { recursive: true });
463
+ atomicWriteText(resolved, text); // bytes as given (no line-ending normalisation), temp + rename
464
+ return resolved;
465
+ }
466
+
467
+ // ---- execution description (does NOT gate) ------------------------------
468
+
469
+ /**
470
+ * Describe HOW to run a tool's invoke. Returns { type, run } where `run` is a
471
+ * zero-arg function returning a Promise of the tool result. resolveExecution
472
+ * performs NO gating - toolfunnel_run_tool / gated-run.js fires the PreToolUse hook
473
+ * and only then calls `run()`. Keeping the gate
474
+ * out of here keeps the safety case in one auditable place.
475
+ *
476
+ * - script invoke: run() spawns node on the host-local script (via the injected
477
+ * runScript), passing args in TOOLFUNNEL_TOOL_ARGS.
478
+ * - shell invoke: run() does NOT execute here. It returns a descriptor
479
+ * { type:'shell', command, args } so the gated runner owns the actual shell
480
+ * spawn (the broadest power) behind the gate. This keeps arbitrary shell
481
+ * execution out of the un-gated register module by design.
482
+ *
483
+ * @param {string} id
484
+ * @param {any} args structured args forwarded to the tool
485
+ * @returns {{ type: string, run: () => Promise<any> }}
486
+ */
487
+ resolveExecution(id, args) {
488
+ const entry = this._byId.get(id);
489
+ if (!entry) throw new Error(`Registry.resolveExecution: unknown tool id "${id}"`);
490
+ const mode = resolveMode(entry);
491
+
492
+ // ---- reference mode: nothing runs here. ----------------------------------
493
+ // The connected AI performs the action in its own environment. We hand back the
494
+ // tool's instructions (no `run`) so the run-path takes the no-spawn branch - a
495
+ // reference tool never spawns server-side. The instruction HANDOFF is still gated
496
+ // in protocol.runTool (a PreToolUse deny withholds the instructions); it's execution,
497
+ // not the gate, that's absent here.
498
+ if (mode === 'reference') {
499
+ return {
500
+ type: 'reference',
501
+ mode: 'reference',
502
+ instructions: typeof entry.instructions === 'string' ? entry.instructions : '',
503
+ };
504
+ }
505
+
506
+ const invoke = entry.invoke;
507
+
508
+ if (invoke.type === 'script') {
509
+ const scriptsRoot = this._scriptsRoot;
510
+ const runScript = this._runScript;
511
+ return {
512
+ type: 'script',
513
+ mode: 'gateway',
514
+ run: () => runScript(scriptsRoot, invoke, args),
515
+ };
516
+ }
517
+
518
+ if (invoke.type === 'shell') {
519
+ // Deliberately do NOT spawn a shell here. Hand the descriptor to the
520
+ // gated runner, which executes shell only after PreToolUse allows it.
521
+ return {
522
+ type: 'shell',
523
+ mode: 'gateway',
524
+ run: async () => ({
525
+ deferred: true,
526
+ type: 'shell',
527
+ command: invoke.command,
528
+ args: args ?? null,
529
+ }),
530
+ };
531
+ }
532
+
533
+ // validateEntry guards the load path, but guard here too for late mutation.
534
+ throw new Error(`Registry.resolveExecution: unsupported invoke.type for "${id}"`);
535
+ }
536
+
537
+ /**
538
+ * The resolved execution mode for a tool ("gateway" | "reference"). Exposed so callers
539
+ * (UI, tests) can ask without resolving a full executor. Throws on unknown id.
540
+ * @param {string} id
541
+ * @returns {"gateway"|"reference"}
542
+ */
543
+ mode(id) {
544
+ const entry = this._byId.get(id);
545
+ if (!entry) throw new Error(`Registry.mode: unknown tool id "${id}"`);
546
+ return resolveMode(entry);
547
+ }
548
+
549
+ // ---- internals ----------------------------------------------------------
550
+
551
+ /** Serialise current state back to the register file atomically. */
552
+ _persist() {
553
+ const obj = {
554
+ version: this._version,
555
+ description: this._description,
556
+ tools: [...this._byId.values()],
557
+ };
558
+ atomicWriteJson(this._filePath, obj);
559
+ }
560
+
561
+ /** @returns {number} */
562
+ get size() {
563
+ return this._byId.size;
564
+ }
565
+ }
566
+
567
+ /**
568
+ * loadRegistry(path) -> Registry
569
+ *
570
+ * Reads + parses the register JSON at `filePath` and returns a Registry bound to
571
+ * it. Throws a clear error on a missing/malformed file or a bad entry shape so
572
+ * misconfiguration is caught at load time, not at model-call time.
573
+ *
574
+ * @param {string} filePath
575
+ * @param {object} [opts] forwarded to the Registry ctor (scriptsRoot, runScript)
576
+ * @returns {Registry}
577
+ */
578
+ function loadRegistry(filePath, opts = {}) {
579
+ if (typeof filePath !== 'string' || filePath.length === 0) {
580
+ throw new Error('loadRegistry: filePath (non-empty string) is required');
581
+ }
582
+ let raw;
583
+ try {
584
+ raw = fs.readFileSync(filePath, 'utf8');
585
+ } catch (err) {
586
+ throw new Error(`loadRegistry: cannot read register at "${filePath}": ${err.message}`);
587
+ }
588
+ let data;
589
+ try {
590
+ data = JSON.parse(raw);
591
+ } catch (err) {
592
+ throw new Error(`loadRegistry: register at "${filePath}" is not valid JSON: ${err.message}`);
593
+ }
594
+ return new Registry({ filePath, data, ...opts });
595
+ }
596
+
597
+ module.exports = {
598
+ Registry,
599
+ loadRegistry,
600
+ // exported for unit tests / the gated runner
601
+ atomicWriteJson,
602
+ validateEntry,
603
+ defaultRunScript,
604
+ resolveMode,
605
+ VALID_INVOKE_TYPES,
606
+ VALID_MODES,
607
+ };