toolfunnel 0.6.0 → 0.7.1
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 +228 -231
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +451 -372
- package/docs/KNOWN_BUGS.md +80 -99
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -70
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +380 -350
- package/package.json +3 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +353 -344
- package/src/core/child-reaper.js +61 -0
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +329 -275
- package/src/core/hook-loader.js +824 -791
- package/src/core/hook-runner.js +566 -346
- package/src/core/logger.js +297 -171
- package/src/core/matcher.js +100 -51
- package/src/core/metrics.js +75 -75
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +152 -152
- package/src/extend/add-mcp.md +119 -122
- package/src/extend/configure.md +88 -87
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -187
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +1719 -1246
- package/src/mcp/expose-store.js +577 -460
- package/src/mcp/gated-run.js +210 -201
- package/src/mcp/http-transport.js +1563 -1311
- package/src/mcp/mcp-client.js +1070 -907
- package/src/mcp/modern.js +406 -372
- package/src/mcp/protocol.js +468 -468
- package/src/mcp/server.js +3112 -2688
- package/src/tools/registry.js +661 -513
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1187 -1174
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1616 -1348
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
- 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/core/metrics.js
CHANGED
|
@@ -1,75 +1,75 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* metrics.js - in-memory, per-process tool-call counters (lightweight observability).
|
|
5
|
-
*
|
|
6
|
-
* A privileged gateway should be able to answer "what has run through me, and how often did it
|
|
7
|
-
* fail?" without standing up a metrics stack. This is the zero-dependency MVP of that: process-
|
|
8
|
-
* lifetime counters incremented at the single tools/call chokepoint and surfaced via the HTTP
|
|
9
|
-
* `/health` endpoint and the admin UI. It is NOT persistent (counts reset when the host restarts)
|
|
10
|
-
* and NOT a substitute for the JSONL audit log (src/core/logger.js, which records individual events
|
|
11
|
-
* to disk when enabled) - the two are complementary: the log is the durable per-event record, this
|
|
12
|
-
* is the cheap live aggregate.
|
|
13
|
-
*
|
|
14
|
-
* Design rules (same discipline as logger.js):
|
|
15
|
-
* - Zero dependencies. Plain objects + integers.
|
|
16
|
-
* - record() / snapshot() NEVER throw - instrumentation must never break a tool call.
|
|
17
|
-
* - In-memory only; no files, no timers, no network.
|
|
18
|
-
*
|
|
19
|
-
* CommonJS only.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
const state = {
|
|
23
|
-
startedAt: new Date().toISOString(),
|
|
24
|
-
calls: 0,
|
|
25
|
-
errors: 0,
|
|
26
|
-
byTool: Object.create(null), // name -> { calls, errors }
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* record - count one tools/call outcome. `ok:false` (an isError result - a tool failure OR a
|
|
31
|
-
* PreToolUse denial, which both surface as an error envelope) increments the error counters.
|
|
32
|
-
* @param {{ tool?:string, ok?:boolean }} ev
|
|
33
|
-
*/
|
|
34
|
-
function record(ev) {
|
|
35
|
-
try {
|
|
36
|
-
const tool = ev && typeof ev.tool === 'string' && ev.tool.length > 0 ? ev.tool : 'unknown';
|
|
37
|
-
const ok = !(ev && ev.ok === false);
|
|
38
|
-
state.calls += 1;
|
|
39
|
-
if (!ok) state.errors += 1;
|
|
40
|
-
let t = state.byTool[tool];
|
|
41
|
-
if (!t) {
|
|
42
|
-
t = { calls: 0, errors: 0 };
|
|
43
|
-
state.byTool[tool] = t;
|
|
44
|
-
}
|
|
45
|
-
t.calls += 1;
|
|
46
|
-
if (!ok) t.errors += 1;
|
|
47
|
-
} catch (_e) {
|
|
48
|
-
/* instrumentation must never break the caller */
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* snapshot - a fresh, mutation-safe copy of the counters for /health + the UI. NEVER throws.
|
|
54
|
-
* @returns {{ startedAt:string, calls:number, errors:number, byTool:Object<string,{calls:number,errors:number}> }}
|
|
55
|
-
*/
|
|
56
|
-
function snapshot() {
|
|
57
|
-
try {
|
|
58
|
-
const byTool = {};
|
|
59
|
-
for (const k of Object.keys(state.byTool)) {
|
|
60
|
-
byTool[k] = { calls: state.byTool[k].calls, errors: state.byTool[k].errors };
|
|
61
|
-
}
|
|
62
|
-
return { startedAt: state.startedAt, calls: state.calls, errors: state.errors, byTool };
|
|
63
|
-
} catch (_e) {
|
|
64
|
-
return { startedAt: state.startedAt, calls: 0, errors: 0, byTool: {} };
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** reset - zero the counters (tests). The startedAt stamp is left as the process start. */
|
|
69
|
-
function reset() {
|
|
70
|
-
state.calls = 0;
|
|
71
|
-
state.errors = 0;
|
|
72
|
-
state.byTool = Object.create(null);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
module.exports = { record, snapshot, reset };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* metrics.js - in-memory, per-process tool-call counters (lightweight observability).
|
|
5
|
+
*
|
|
6
|
+
* A privileged gateway should be able to answer "what has run through me, and how often did it
|
|
7
|
+
* fail?" without standing up a metrics stack. This is the zero-dependency MVP of that: process-
|
|
8
|
+
* lifetime counters incremented at the single tools/call chokepoint and surfaced via the HTTP
|
|
9
|
+
* `/health` endpoint and the admin UI. It is NOT persistent (counts reset when the host restarts)
|
|
10
|
+
* and NOT a substitute for the JSONL audit log (src/core/logger.js, which records individual events
|
|
11
|
+
* to disk when enabled) - the two are complementary: the log is the durable per-event record, this
|
|
12
|
+
* is the cheap live aggregate.
|
|
13
|
+
*
|
|
14
|
+
* Design rules (same discipline as logger.js):
|
|
15
|
+
* - Zero dependencies. Plain objects + integers.
|
|
16
|
+
* - record() / snapshot() NEVER throw - instrumentation must never break a tool call.
|
|
17
|
+
* - In-memory only; no files, no timers, no network.
|
|
18
|
+
*
|
|
19
|
+
* CommonJS only.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const state = {
|
|
23
|
+
startedAt: new Date().toISOString(),
|
|
24
|
+
calls: 0,
|
|
25
|
+
errors: 0,
|
|
26
|
+
byTool: Object.create(null), // name -> { calls, errors }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* record - count one tools/call outcome. `ok:false` (an isError result - a tool failure OR a
|
|
31
|
+
* PreToolUse denial, which both surface as an error envelope) increments the error counters.
|
|
32
|
+
* @param {{ tool?:string, ok?:boolean }} ev
|
|
33
|
+
*/
|
|
34
|
+
function record(ev) {
|
|
35
|
+
try {
|
|
36
|
+
const tool = ev && typeof ev.tool === 'string' && ev.tool.length > 0 ? ev.tool : 'unknown';
|
|
37
|
+
const ok = !(ev && ev.ok === false);
|
|
38
|
+
state.calls += 1;
|
|
39
|
+
if (!ok) state.errors += 1;
|
|
40
|
+
let t = state.byTool[tool];
|
|
41
|
+
if (!t) {
|
|
42
|
+
t = { calls: 0, errors: 0 };
|
|
43
|
+
state.byTool[tool] = t;
|
|
44
|
+
}
|
|
45
|
+
t.calls += 1;
|
|
46
|
+
if (!ok) t.errors += 1;
|
|
47
|
+
} catch (_e) {
|
|
48
|
+
/* instrumentation must never break the caller */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* snapshot - a fresh, mutation-safe copy of the counters for /health + the UI. NEVER throws.
|
|
54
|
+
* @returns {{ startedAt:string, calls:number, errors:number, byTool:Object<string,{calls:number,errors:number}> }}
|
|
55
|
+
*/
|
|
56
|
+
function snapshot() {
|
|
57
|
+
try {
|
|
58
|
+
const byTool = {};
|
|
59
|
+
for (const k of Object.keys(state.byTool)) {
|
|
60
|
+
byTool[k] = { calls: state.byTool[k].calls, errors: state.byTool[k].errors };
|
|
61
|
+
}
|
|
62
|
+
return { startedAt: state.startedAt, calls: state.calls, errors: state.errors, byTool };
|
|
63
|
+
} catch (_e) {
|
|
64
|
+
return { startedAt: state.startedAt, calls: 0, errors: 0, byTool: {} };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** reset - zero the counters (tests). The startedAt stamp is left as the process start. */
|
|
69
|
+
function reset() {
|
|
70
|
+
state.calls = 0;
|
|
71
|
+
state.errors = 0;
|
|
72
|
+
state.byTool = Object.create(null);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { record, snapshot, reset };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* rename-migration.js - keep gates and per-tool state attached across a SURFACED-NAME change
|
|
5
|
+
* (0.7.0; the rename-safety.test.js invariants are the acceptance).
|
|
6
|
+
*
|
|
7
|
+
* A tool's SURFACED name is the string the PreToolUse gate matches and the tools.state.json
|
|
8
|
+
* key is written against. 0.6.0 let that name change (an expose `as` edit, an enabled flip, a
|
|
9
|
+
* local display-name edit) while the gate matcher and the state key silently kept the OLD
|
|
10
|
+
* string - a deny gate stopped firing and a disabled tool resurrected. The policy here,
|
|
11
|
+
* MATCHER-MIGRATE-ON-RENAME:
|
|
12
|
+
*
|
|
13
|
+
* - A WILDCARD matcher ('', '*', unset) fires for every tool name - it already covers the
|
|
14
|
+
* new name; untouched.
|
|
15
|
+
* - An EXACT-LITERAL matcher - the `escapeRegex(oldName)` shape every ToolFunnel surface
|
|
16
|
+
* (tf_* tools, the UI) writes - is REWRITTEN to `escapeRegex(newName)`.
|
|
17
|
+
* - Any OTHER matcher that would fire for the old name (same anchored-regex semantics as
|
|
18
|
+
* core/matcher.js, the gate's own matching) is one WE DID NOT AUTHOR and cannot safely
|
|
19
|
+
* rewrite: the rename is REFUSED with the hook named. Update the hook first, then rename.
|
|
20
|
+
* - tools.state.json: the old key's entry (enabled/hidden/hot) MOVES to the new key. A
|
|
21
|
+
* collision (both keys present with different content) refuses - never clobber curation.
|
|
22
|
+
*
|
|
23
|
+
* Validation runs COMPLETELY before the first write, and the caller invokes this BEFORE
|
|
24
|
+
* persisting its own rename - a refusal leaves every file untouched.
|
|
25
|
+
*
|
|
26
|
+
* CommonJS only. Node built-ins + core/matcher only (core/ stays headless).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const { matches, matcherError } = require('./matcher');
|
|
32
|
+
|
|
33
|
+
/** Regex-escape a literal so a matcher built from it FULL-matches the string verbatim.
|
|
34
|
+
* (Same shape as ui/server.js escapeRegex - the shape ToolFunnel-authored matchers use.) */
|
|
35
|
+
function escapeRegex(str) {
|
|
36
|
+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Copied exactly from registry.js::atomicWriteJson - crash leaves old file or new, never half. */
|
|
40
|
+
function atomicWriteJson(targetPath, obj) {
|
|
41
|
+
const dir = path.dirname(targetPath);
|
|
42
|
+
const base = path.basename(targetPath);
|
|
43
|
+
const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
|
|
44
|
+
const data = JSON.stringify(obj, null, 2) + '\n';
|
|
45
|
+
const fd = fs.openSync(tmp, 'w');
|
|
46
|
+
try {
|
|
47
|
+
fs.writeSync(fd, data, 0, 'utf8');
|
|
48
|
+
fs.fsyncSync(fd);
|
|
49
|
+
} finally {
|
|
50
|
+
fs.closeSync(fd);
|
|
51
|
+
}
|
|
52
|
+
fs.renameSync(tmp, targetPath);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readJsonIfPresent(p) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
58
|
+
} catch (_e) {
|
|
59
|
+
return null; // absent or unreadable -> nothing to migrate there
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Migrate every gate matcher and state key from oldName to newName, or throw a refusal.
|
|
65
|
+
* @param {object} o
|
|
66
|
+
* @param {string} o.hooksPath hooks.manifest.json path (may be absent on disk)
|
|
67
|
+
* @param {string} o.statePath tools.state.json path (may be absent on disk)
|
|
68
|
+
* @param {string} o.oldName the surfaced name being retired
|
|
69
|
+
* @param {string} o.newName the surfaced name taking over
|
|
70
|
+
*/
|
|
71
|
+
function migrateSurfacedName(o) {
|
|
72
|
+
const { hooksPath, statePath, oldName, newName } = o || {};
|
|
73
|
+
if (typeof oldName !== 'string' || typeof newName !== 'string' || oldName === newName) return;
|
|
74
|
+
|
|
75
|
+
// ── Phase A: VALIDATE everything, write nothing ─────────────────────────────────────────────
|
|
76
|
+
const manifest = hooksPath ? readJsonIfPresent(hooksPath) : null;
|
|
77
|
+
const hooks = manifest && Array.isArray(manifest.hooks) ? manifest.hooks : [];
|
|
78
|
+
const literal = escapeRegex(oldName);
|
|
79
|
+
const rewrites = [];
|
|
80
|
+
for (const h of hooks) {
|
|
81
|
+
if (!h || typeof h !== 'object') continue;
|
|
82
|
+
const m = h.matcher;
|
|
83
|
+
if (m === undefined || m === null || m === '' || m === '*') continue; // wildcard covers both names
|
|
84
|
+
if (m === literal || m === oldName) {
|
|
85
|
+
// The exact-literal shape ToolFunnel authors (escaped, or the bare name when it contains
|
|
86
|
+
// no regex specials - the two are identical then, but hand-written manifests may carry
|
|
87
|
+
// the bare form for a name that DOES contain specials, e.g. "my.tool").
|
|
88
|
+
rewrites.push(h);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// A matcher that does not COMPILE cannot be evaluated against oldName at all - matches()
|
|
92
|
+
// would answer false, silently carrying the rename past a hook this code cannot read.
|
|
93
|
+
// Same policy as a matching non-literal: refuse, name the hook, touch nothing.
|
|
94
|
+
// (addEntry refuses these at authoring, so only a hand-edited manifest reaches here.)
|
|
95
|
+
const compileProblem = matcherError(m);
|
|
96
|
+
if (compileProblem) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`rename refused: hook "${h.id || '(unnamed)'}" matcher ${JSON.stringify(m)} does not ` +
|
|
99
|
+
`compile (${compileProblem}) - it cannot be checked against "${oldName}", so the rename ` +
|
|
100
|
+
'cannot proceed safely. Fix the hook first, then rename'
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (matches(m, oldName)) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`rename refused: hook "${h.id || '(unnamed)'}" matcher "${m}" matches "${oldName}" but is ` +
|
|
106
|
+
'not an exact-literal matcher ToolFunnel can rewrite - update the hook to cover ' +
|
|
107
|
+
`"${newName}" first, then rename`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const state = statePath ? readJsonIfPresent(statePath) : null;
|
|
113
|
+
const hasOldState = !!(state && Object.prototype.hasOwnProperty.call(state, oldName));
|
|
114
|
+
if (hasOldState && Object.prototype.hasOwnProperty.call(state, newName) &&
|
|
115
|
+
JSON.stringify(state[oldName]) !== JSON.stringify(state[newName])) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`rename refused: tools.state.json already has an entry for "${newName}" that differs from ` +
|
|
118
|
+
`"${oldName}"'s - resolve the curation collision first, then rename`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Phase B: WRITE (all validation passed) ──────────────────────────────────────────────────
|
|
123
|
+
if (rewrites.length) {
|
|
124
|
+
for (const h of rewrites) h.matcher = escapeRegex(newName);
|
|
125
|
+
atomicWriteJson(hooksPath, manifest);
|
|
126
|
+
}
|
|
127
|
+
if (hasOldState) {
|
|
128
|
+
state[newName] = state[oldName];
|
|
129
|
+
delete state[oldName];
|
|
130
|
+
atomicWriteJson(statePath, state);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Convenience for the two stores: derive the sibling config paths from the CONFIG-HOME layout
|
|
136
|
+
* (<root>/mcp/expose.json, <root>/tools/tools.register.json, <root>/tools/tools.state.json,
|
|
137
|
+
* <root>/hooks/hooks.manifest.json) and migrate. `storeFilePath` is the calling store's own
|
|
138
|
+
* file; its grandparent is the home root.
|
|
139
|
+
*
|
|
140
|
+
* `opts.migrateState` (default TRUE) selects whether the tools.state.json key moves with the
|
|
141
|
+
* name. The gate matcher ALWAYS moves - it genuinely fires on the surfaced name for both stores.
|
|
142
|
+
* The state key does NOT, because the overlay has TWO key namespaces:
|
|
143
|
+
* - UPSTREAM tools are keyed by their SURFACED name (server.js:638,733,924; ui:1273) - an
|
|
144
|
+
* expose `as` edit changes that key, so ExposeStore migrates state (the default).
|
|
145
|
+
* - LOCAL register tools are keyed by their register ID (server.js:290,337,814-815,952;
|
|
146
|
+
* ui:326-328; tf-tool-set.js) - an id is immutable and a display-name edit does not touch
|
|
147
|
+
* it, so Registry passes migrateState:false. Moving the key there would ORPHAN the entry:
|
|
148
|
+
* the readers still look up the id, find nothing, and fall back to the defaults - which are
|
|
149
|
+
* enabled - so a tool deliberately switched off would come back on (rename-safety S5).
|
|
150
|
+
* Bites whenever name === id, i.e. every tf_* management tool.
|
|
151
|
+
*/
|
|
152
|
+
function migrateForStoreFile(storeFilePath, oldName, newName, opts) {
|
|
153
|
+
const root = path.dirname(path.dirname(path.resolve(storeFilePath)));
|
|
154
|
+
const migrateState = !(opts && opts.migrateState === false);
|
|
155
|
+
migrateSurfacedName({
|
|
156
|
+
hooksPath: path.join(root, 'hooks', 'hooks.manifest.json'),
|
|
157
|
+
statePath: migrateState ? path.join(root, 'tools', 'tools.state.json') : null,
|
|
158
|
+
oldName,
|
|
159
|
+
newName,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { migrateSurfacedName, migrateForStoreFile, escapeRegex };
|
package/src/core/requires.js
CHANGED
|
@@ -1,138 +1,138 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* requires.js - runtime-requirement preflight (the packaging story's honesty layer).
|
|
5
|
-
*
|
|
6
|
-
* A shipped pack's tools may need runtimes the gateway itself does not (python for a PDF tool,
|
|
7
|
-
* git for a repo tool). The pack DECLARES them in its toolfunnel.json - the identity file that
|
|
8
|
-
* already travels with every config home - and the gateway probes them ONCE at startup:
|
|
9
|
-
*
|
|
10
|
-
* "requires": [
|
|
11
|
-
* { "command": "python", "min": "3.10", "why": "the pdf-extract tools" },
|
|
12
|
-
* { "command": "git" }
|
|
13
|
-
* ]
|
|
14
|
-
*
|
|
15
|
-
* Probes are ADVISORY, never fatal: a missing runtime breaks only the tools that need it, so the
|
|
16
|
-
* gateway starts and the operator gets a clear stderr line naming what is missing, the version
|
|
17
|
-
* found vs needed, and the declared "why". Fields per entry (only `command` is required):
|
|
18
|
-
* command the executable to probe (e.g. "python", "node", "git")
|
|
19
|
-
* versionArg the flag that prints a version (default "--version")
|
|
20
|
-
* min minimum version, dotted numerics (e.g. "3.10" / "18" / "3.10.2")
|
|
21
|
-
* why shown in the warning so the operator knows which tools care
|
|
22
|
-
*
|
|
23
|
-
* Zero new dependencies: child_process probes + a hand-rolled dotted-numeric compare. NO semver
|
|
24
|
-
* library - by the packaging hard constraint, and dotted numerics cover real runtime versions.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
const fs = require('node:fs');
|
|
28
|
-
const path = require('node:path');
|
|
29
|
-
const { spawnSync } = require('node:child_process');
|
|
30
|
-
|
|
31
|
-
/** Parse the FIRST dotted-numeric version out of a probe's output ("Python 3.12.1" -> [3,12,1]).
|
|
32
|
-
* Returns null when no version-shaped token is present. */
|
|
33
|
-
function parseVersion(text) {
|
|
34
|
-
const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(text || ''));
|
|
35
|
-
if (!m) {
|
|
36
|
-
const single = /\b(\d+)\b/.exec(String(text || ''));
|
|
37
|
-
return single ? [Number(single[1]), 0, 0] : null;
|
|
38
|
-
}
|
|
39
|
-
return [Number(m[1]), Number(m[2]), Number(m[3] || 0)];
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Hand-rolled dotted-numeric compare: -1 | 0 | 1 for a<b | a==b | a>b. */
|
|
43
|
-
function compareVersions(a, b) {
|
|
44
|
-
for (let i = 0; i < 3; i += 1) {
|
|
45
|
-
const x = a[i] || 0;
|
|
46
|
-
const y = b[i] || 0;
|
|
47
|
-
if (x < y) return -1;
|
|
48
|
-
if (x > y) return 1;
|
|
49
|
-
}
|
|
50
|
-
return 0;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Probe one command for its version string. On Windows a PATHEXT shim (a .cmd like npx) cannot be
|
|
55
|
-
* spawned shell:false (ENOENT/EINVAL - the CVE-2024-27980 mitigation), so a failed direct probe is
|
|
56
|
-
* retried through cmd.exe, mirroring mcp-client's winLaunch.
|
|
57
|
-
* @param {string} command
|
|
58
|
-
* @param {string} versionArg
|
|
59
|
-
* @returns {{found: boolean, version: (number[]|null), raw: string}}
|
|
60
|
-
*/
|
|
61
|
-
function probe(command, versionArg) {
|
|
62
|
-
const runOnce = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8', windowsHide: true, timeout: 5000 });
|
|
63
|
-
let res = runOnce(command, [versionArg]);
|
|
64
|
-
if (res.error && process.platform === 'win32') {
|
|
65
|
-
const comspec = process.env.ComSpec || 'cmd.exe';
|
|
66
|
-
res = runOnce(comspec, ['/c', command, versionArg]);
|
|
67
|
-
}
|
|
68
|
-
if (res.error || res.status == null) return { found: false, version: null, raw: '' };
|
|
69
|
-
const raw = `${res.stdout || ''} ${res.stderr || ''}`.trim();
|
|
70
|
-
const version = parseVersion(raw);
|
|
71
|
-
// "Spawned" is not "exists": the cmd.exe fallback launches fine for a NONEXISTENT command and
|
|
72
|
-
// exits non-zero ("is not recognized..."). Existence = a clean exit OR a version-shaped answer.
|
|
73
|
-
const found = res.status === 0 || version !== null;
|
|
74
|
-
return { found, version: found ? version : null, raw };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Check the `requires` declared in `<home>/toolfunnel.json`. Returns the problem list - the
|
|
79
|
-
* caller decides how to surface it (the gateway logs each to stderr and starts anyway). NEVER
|
|
80
|
-
* throws; a malformed requires entry is itself reported as a problem rather than crashing.
|
|
81
|
-
* @param {string} home the resolved config home
|
|
82
|
-
* @returns {Array<{command:string, problem:string}>}
|
|
83
|
-
*/
|
|
84
|
-
function checkRequires(home) {
|
|
85
|
-
let cfg;
|
|
86
|
-
try {
|
|
87
|
-
cfg = JSON.parse(fs.readFileSync(path.join(home, 'toolfunnel.json'), 'utf8'));
|
|
88
|
-
} catch (_e) {
|
|
89
|
-
return []; // absent/bad identity file -> nothing declared
|
|
90
|
-
}
|
|
91
|
-
const list = cfg && Array.isArray(cfg.requires) ? cfg.requires : [];
|
|
92
|
-
const problems = [];
|
|
93
|
-
for (const entry of list) {
|
|
94
|
-
if (!entry || typeof entry !== 'object' || typeof entry.command !== 'string' || entry.command.trim() === '') {
|
|
95
|
-
problems.push({ command: String(entry && entry.command), problem: 'malformed requires entry (needs a "command" string)' });
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
const command = entry.command.trim();
|
|
99
|
-
// A requires command is a bare PROGRAM NAME, never a shell string. This is load-bearing on
|
|
100
|
-
// Windows: the cmd.exe shim fallback below hands the token to cmd, where an unquoted
|
|
101
|
-
// metacharacter ("x&
|
|
102
|
-
// beyond `<program> <versionArg>`. Reject anything shell-shaped up front (also catches paths:
|
|
103
|
-
// declare tools by PATH name, which is the only portable claim a pack can make anyway).
|
|
104
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(command)) {
|
|
105
|
-
problems.push({ command, problem: `requires.command "${command}" is not a bare program name (no paths, spaces, or shell characters)` });
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
const why = typeof entry.why === 'string' && entry.why ? ` (needed for: ${entry.why})` : '';
|
|
109
|
-
const versionArg = typeof entry.versionArg === 'string' && /^--?[A-Za-z][A-Za-z-]*$/.test(entry.versionArg)
|
|
110
|
-
? entry.versionArg
|
|
111
|
-
: '--version'; // same rule: a version FLAG, never a shell fragment
|
|
112
|
-
const p = probe(command, versionArg);
|
|
113
|
-
if (!p.found) {
|
|
114
|
-
problems.push({ command, problem: `"${command}" was not found on PATH${why}` });
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
if (typeof entry.min === 'string' && entry.min.trim() !== '') {
|
|
118
|
-
const min = parseVersion(entry.min);
|
|
119
|
-
if (!min) {
|
|
120
|
-
problems.push({ command, problem: `requires.min "${entry.min}" is not a dotted numeric version` });
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
if (!p.version) {
|
|
124
|
-
problems.push({ command, problem: `"${command}" answered but printed no recognisable version (wanted >= ${entry.min})${why}` });
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
if (compareVersions(p.version, min) < 0) {
|
|
128
|
-
problems.push({
|
|
129
|
-
command,
|
|
130
|
-
problem: `"${command}" is ${p.version.join('.')} but this setup declares >= ${entry.min}${why}`,
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return problems;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
module.exports = { checkRequires, parseVersion, compareVersions, probe };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* requires.js - runtime-requirement preflight (the packaging story's honesty layer).
|
|
5
|
+
*
|
|
6
|
+
* A shipped pack's tools may need runtimes the gateway itself does not (python for a PDF tool,
|
|
7
|
+
* git for a repo tool). The pack DECLARES them in its toolfunnel.json - the identity file that
|
|
8
|
+
* already travels with every config home - and the gateway probes them ONCE at startup:
|
|
9
|
+
*
|
|
10
|
+
* "requires": [
|
|
11
|
+
* { "command": "python", "min": "3.10", "why": "the pdf-extract tools" },
|
|
12
|
+
* { "command": "git" }
|
|
13
|
+
* ]
|
|
14
|
+
*
|
|
15
|
+
* Probes are ADVISORY, never fatal: a missing runtime breaks only the tools that need it, so the
|
|
16
|
+
* gateway starts and the operator gets a clear stderr line naming what is missing, the version
|
|
17
|
+
* found vs needed, and the declared "why". Fields per entry (only `command` is required):
|
|
18
|
+
* command the executable to probe (e.g. "python", "node", "git")
|
|
19
|
+
* versionArg the flag that prints a version (default "--version")
|
|
20
|
+
* min minimum version, dotted numerics (e.g. "3.10" / "18" / "3.10.2")
|
|
21
|
+
* why shown in the warning so the operator knows which tools care
|
|
22
|
+
*
|
|
23
|
+
* Zero new dependencies: child_process probes + a hand-rolled dotted-numeric compare. NO semver
|
|
24
|
+
* library - by the packaging hard constraint, and dotted numerics cover real runtime versions.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const { spawnSync } = require('node:child_process');
|
|
30
|
+
|
|
31
|
+
/** Parse the FIRST dotted-numeric version out of a probe's output ("Python 3.12.1" -> [3,12,1]).
|
|
32
|
+
* Returns null when no version-shaped token is present. */
|
|
33
|
+
function parseVersion(text) {
|
|
34
|
+
const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(text || ''));
|
|
35
|
+
if (!m) {
|
|
36
|
+
const single = /\b(\d+)\b/.exec(String(text || ''));
|
|
37
|
+
return single ? [Number(single[1]), 0, 0] : null;
|
|
38
|
+
}
|
|
39
|
+
return [Number(m[1]), Number(m[2]), Number(m[3] || 0)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Hand-rolled dotted-numeric compare: -1 | 0 | 1 for a<b | a==b | a>b. */
|
|
43
|
+
function compareVersions(a, b) {
|
|
44
|
+
for (let i = 0; i < 3; i += 1) {
|
|
45
|
+
const x = a[i] || 0;
|
|
46
|
+
const y = b[i] || 0;
|
|
47
|
+
if (x < y) return -1;
|
|
48
|
+
if (x > y) return 1;
|
|
49
|
+
}
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Probe one command for its version string. On Windows a PATHEXT shim (a .cmd like npx) cannot be
|
|
55
|
+
* spawned shell:false (ENOENT/EINVAL - the CVE-2024-27980 mitigation), so a failed direct probe is
|
|
56
|
+
* retried through cmd.exe, mirroring mcp-client's winLaunch.
|
|
57
|
+
* @param {string} command
|
|
58
|
+
* @param {string} versionArg
|
|
59
|
+
* @returns {{found: boolean, version: (number[]|null), raw: string}}
|
|
60
|
+
*/
|
|
61
|
+
function probe(command, versionArg) {
|
|
62
|
+
const runOnce = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8', windowsHide: true, timeout: 5000 });
|
|
63
|
+
let res = runOnce(command, [versionArg]);
|
|
64
|
+
if (res.error && process.platform === 'win32') {
|
|
65
|
+
const comspec = process.env.ComSpec || 'cmd.exe';
|
|
66
|
+
res = runOnce(comspec, ['/c', command, versionArg]);
|
|
67
|
+
}
|
|
68
|
+
if (res.error || res.status == null) return { found: false, version: null, raw: '' };
|
|
69
|
+
const raw = `${res.stdout || ''} ${res.stderr || ''}`.trim();
|
|
70
|
+
const version = parseVersion(raw);
|
|
71
|
+
// "Spawned" is not "exists": the cmd.exe fallback launches fine for a NONEXISTENT command and
|
|
72
|
+
// exits non-zero ("is not recognized..."). Existence = a clean exit OR a version-shaped answer.
|
|
73
|
+
const found = res.status === 0 || version !== null;
|
|
74
|
+
return { found, version: found ? version : null, raw };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check the `requires` declared in `<home>/toolfunnel.json`. Returns the problem list - the
|
|
79
|
+
* caller decides how to surface it (the gateway logs each to stderr and starts anyway). NEVER
|
|
80
|
+
* throws; a malformed requires entry is itself reported as a problem rather than crashing.
|
|
81
|
+
* @param {string} home the resolved config home
|
|
82
|
+
* @returns {Array<{command:string, problem:string}>}
|
|
83
|
+
*/
|
|
84
|
+
function checkRequires(home) {
|
|
85
|
+
let cfg;
|
|
86
|
+
try {
|
|
87
|
+
cfg = JSON.parse(fs.readFileSync(path.join(home, 'toolfunnel.json'), 'utf8'));
|
|
88
|
+
} catch (_e) {
|
|
89
|
+
return []; // absent/bad identity file -> nothing declared
|
|
90
|
+
}
|
|
91
|
+
const list = cfg && Array.isArray(cfg.requires) ? cfg.requires : [];
|
|
92
|
+
const problems = [];
|
|
93
|
+
for (const entry of list) {
|
|
94
|
+
if (!entry || typeof entry !== 'object' || typeof entry.command !== 'string' || entry.command.trim() === '') {
|
|
95
|
+
problems.push({ command: String(entry && entry.command), problem: 'malformed requires entry (needs a "command" string)' });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const command = entry.command.trim();
|
|
99
|
+
// A requires command is a bare PROGRAM NAME, never a shell string. This is load-bearing on
|
|
100
|
+
// Windows: the cmd.exe shim fallback below hands the token to cmd, where an unquoted
|
|
101
|
+
// metacharacter ("x&denied") would CHAIN commands - a probe must never be able to run anything
|
|
102
|
+
// beyond `<program> <versionArg>`. Reject anything shell-shaped up front (also catches paths:
|
|
103
|
+
// declare tools by PATH name, which is the only portable claim a pack can make anyway).
|
|
104
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(command)) {
|
|
105
|
+
problems.push({ command, problem: `requires.command "${command}" is not a bare program name (no paths, spaces, or shell characters)` });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const why = typeof entry.why === 'string' && entry.why ? ` (needed for: ${entry.why})` : '';
|
|
109
|
+
const versionArg = typeof entry.versionArg === 'string' && /^--?[A-Za-z][A-Za-z-]*$/.test(entry.versionArg)
|
|
110
|
+
? entry.versionArg
|
|
111
|
+
: '--version'; // same rule: a version FLAG, never a shell fragment
|
|
112
|
+
const p = probe(command, versionArg);
|
|
113
|
+
if (!p.found) {
|
|
114
|
+
problems.push({ command, problem: `"${command}" was not found on PATH${why}` });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (typeof entry.min === 'string' && entry.min.trim() !== '') {
|
|
118
|
+
const min = parseVersion(entry.min);
|
|
119
|
+
if (!min) {
|
|
120
|
+
problems.push({ command, problem: `requires.min "${entry.min}" is not a dotted numeric version` });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!p.version) {
|
|
124
|
+
problems.push({ command, problem: `"${command}" answered but printed no recognisable version (wanted >= ${entry.min})${why}` });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (compareVersions(p.version, min) < 0) {
|
|
128
|
+
problems.push({
|
|
129
|
+
command,
|
|
130
|
+
problem: `"${command}" is ${p.version.join('.')} but this setup declares >= ${entry.min}${why}`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return problems;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { checkRequires, parseVersion, compareVersions, probe };
|