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.
- package/README.md +238 -235
- package/SECURITY.md +84 -84
- package/bin/toolfunnel.js +530 -451
- package/docs/KNOWN_BUGS.md +100 -80
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +84 -84
- package/docs/packaging.md +116 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -39
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +379 -380
- package/package.json +67 -67
- package/src/auth/config.js +172 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +358 -353
- package/src/core/atomic-write.js +97 -0
- package/src/core/child-reaper.js +61 -61
- package/src/core/config-home.js +226 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +304 -329
- package/src/core/hook-loader.js +766 -824
- package/src/core/hook-runner.js +615 -566
- package/src/core/logger.js +295 -297
- package/src/core/matcher.js +100 -100
- package/src/core/metrics.js +90 -75
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +178 -163
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +155 -152
- package/src/extend/add-mcp.md +119 -119
- package/src/extend/configure.md +88 -88
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -190
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +2248 -1674
- package/src/mcp/expose-store.js +755 -577
- package/src/mcp/gated-run.js +210 -210
- package/src/mcp/http-transport.js +1657 -1563
- package/src/mcp/mcp-client.js +1174 -1070
- package/src/mcp/modern.js +409 -406
- package/src/mcp/protocol.js +473 -468
- package/src/mcp/server.js +3317 -3112
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +607 -661
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1246 -1187
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1742 -1616
- 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-accept-change.js +67 -0
- 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 -120
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +130 -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 +16 -5
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* tool-approvals.js - tool descriptor approval ("hash-pinning").
|
|
5
|
+
*
|
|
6
|
+
* An upstream can change what its tools SAY after you attached it. The description (and every
|
|
7
|
+
* parameter description inside the input schema) goes straight into the model's context, so a
|
|
8
|
+
* changed descriptor is the attack whether or not the tool is ever called. This module holds the
|
|
9
|
+
* PURE rules plus the state file; the aggregator applies them at its single listing chokepoint
|
|
10
|
+
* (_listAndCache) so every listing shows only approved text and every execution path can refuse.
|
|
11
|
+
*
|
|
12
|
+
* Rules, per upstream (evaluate):
|
|
13
|
+
* - no record yet -> FIRST USE: approve every tool as listed.
|
|
14
|
+
* - same version, a tool changed/new -> PENDING: the listing keeps the APPROVED descriptor (a new
|
|
15
|
+
* tool stays hidden) and calls refuse until accepted.
|
|
16
|
+
* - version changed -> UPGRADE: auto-approved and reported, unless `strict`
|
|
17
|
+
* (expose.json strictApproval) - then pending as above.
|
|
18
|
+
* - the server NAME changed -> the same as a version change (reported as server-changed);
|
|
19
|
+
* never a fresh first use, or a rename would get past strict.
|
|
20
|
+
* - a server that reports no version -> the stricter branch (treated as "same version").
|
|
21
|
+
* - a removed tool -> its approval stays (harmless); a pending entry for it goes.
|
|
22
|
+
* Known limit, documented: a hostile maintainer can bump the version too - that is what strict is for.
|
|
23
|
+
*
|
|
24
|
+
* What is hashed: the model-facing fields, as canonical JSON (sorted keys): HASHED_FIELDS. `_meta`
|
|
25
|
+
* and `icons` are presentation and excluded. sha256 from node:crypto (zero-dep).
|
|
26
|
+
*
|
|
27
|
+
* The state file (mcp/tool-approvals.json) is USER STATE, never seeded or shipped. It carries the
|
|
28
|
+
* pending entries too, because the UI and CLI (separate processes) accept by moving pending ->
|
|
29
|
+
* approved in the file. Missing = empty. Unreadable = THROW: treating it as empty would silently
|
|
30
|
+
* approve everything as first use, which is exactly what corrupting it would be for.
|
|
31
|
+
*
|
|
32
|
+
* Tool names and upstream ids come from outside, so every map write goes through put() (an own
|
|
33
|
+
* data property even for "__proto__") and every read through own().
|
|
34
|
+
*
|
|
35
|
+
* CommonJS, node built-ins only.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const fs = require('node:fs');
|
|
39
|
+
const crypto = require('node:crypto');
|
|
40
|
+
const { atomicWriteJson } = require('../core/atomic-write');
|
|
41
|
+
|
|
42
|
+
const HASHED_FIELDS = ['name', 'title', 'description', 'inputSchema', 'outputSchema', 'annotations'];
|
|
43
|
+
const FILE_VERSION = 1;
|
|
44
|
+
|
|
45
|
+
const hasOwn = (o, k) => o !== null && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k);
|
|
46
|
+
const own = (o, k) => (hasOwn(o, k) ? o[k] : undefined);
|
|
47
|
+
function put(o, k, v) {
|
|
48
|
+
Object.defineProperty(o, k, { value: v, enumerable: true, writable: true, configurable: true });
|
|
49
|
+
}
|
|
50
|
+
const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
|
51
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
52
|
+
|
|
53
|
+
/** Stable JSON: object keys sorted at every level, undefined members dropped (as JSON does). */
|
|
54
|
+
function canonical(v) {
|
|
55
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v === undefined ? null : v);
|
|
56
|
+
if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']';
|
|
57
|
+
const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
|
|
58
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The hashed, stored and served part of a tool: its HASHED_FIELDS that are present. */
|
|
62
|
+
function descriptorOf(tool) {
|
|
63
|
+
const d = {};
|
|
64
|
+
for (const f of HASHED_FIELDS) {
|
|
65
|
+
if (tool && tool[f] !== undefined) put(d, f, clone(tool[f]));
|
|
66
|
+
}
|
|
67
|
+
return d;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 'sha256:<hex>' over the canonical descriptor. */
|
|
71
|
+
function hashTool(tool) {
|
|
72
|
+
return 'sha256:' + crypto.createHash('sha256').update(canonical(descriptorOf(tool)), 'utf8').digest('hex');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// C0, DEL, C1 and the bidi controls: a server's text must not drive a terminal or reorder a line.
|
|
76
|
+
const UNPRINTABLE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
|
|
77
|
+
const NAMED_ESCAPES = { '\n': '\\n', '\r': '\\r', '\t': '\\t' };
|
|
78
|
+
|
|
79
|
+
/** A server's text made safe to print: control and bidi characters shown as escapes. */
|
|
80
|
+
function printable(s) {
|
|
81
|
+
return String(s).replace(UNPRINTABLE, (c) => NAMED_ESCAPES[c] || '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The HASHED_FIELDS that differ between two descriptors (every present one when `approved` is null). */
|
|
85
|
+
function changedFields(approved, current) {
|
|
86
|
+
return HASHED_FIELDS.filter((f) => hasOwn(approved, f) !== hasOwn(current, f) || canonical(own(approved, f)) !== canonical(own(current, f)));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Apply the rules to one upstream's current listing.
|
|
91
|
+
* @param {object} o
|
|
92
|
+
* @param {object} [o.record] this upstream's stored record, or undefined (never approved)
|
|
93
|
+
* @param {object} [o.serverInfo] { name, version } the upstream reported this connect
|
|
94
|
+
* @param {object[]} o.tools the raw tools/list result
|
|
95
|
+
* @param {boolean} [o.strict] strictApproval: a version change does not auto-approve
|
|
96
|
+
* @param {string} [o.now] ISO time for new entries (tests pass a fixed one)
|
|
97
|
+
* @param {object} [o.writer] { pid, id } of the gateway evaluating, stamped on each pending
|
|
98
|
+
* entry as `by` so another run can tell a live writer from a gone one
|
|
99
|
+
* @returns {{ record: object, effective: object[], pending: object[], events: object[], changed: boolean }}
|
|
100
|
+
* record the new record to store; effective the tools to SERVE (approved descriptors, in the
|
|
101
|
+
* upstream's order; unapproved new tools absent); pending [{tool, reason, approved, current,
|
|
102
|
+
* changed}] for the refusal; events [{kind:'first-use'|'server-changed'|'upgrade', ...}];
|
|
103
|
+
* changed = the record differs from the one passed in (save it).
|
|
104
|
+
*/
|
|
105
|
+
function evaluate({ record, serverInfo, tools, strict, now, writer } = {}) {
|
|
106
|
+
const at = typeof now === 'string' && now ? now : new Date().toISOString();
|
|
107
|
+
const seen = { name: str(serverInfo && serverInfo.name), version: str(serverInfo && serverInfo.version) };
|
|
108
|
+
const current = new Map(); // name -> { tool, hash, descriptor }, first occurrence wins (listing order)
|
|
109
|
+
for (const t of Array.isArray(tools) ? tools : []) {
|
|
110
|
+
if (!t || typeof t.name !== 'string' || !t.name.length || current.has(t.name)) continue;
|
|
111
|
+
current.set(t.name, { tool: t, hash: hashTool(t), descriptor: descriptorOf(t) });
|
|
112
|
+
}
|
|
113
|
+
const before = record ? canonical(record) : null;
|
|
114
|
+
const events = [];
|
|
115
|
+
|
|
116
|
+
if (!record) {
|
|
117
|
+
const approved = {};
|
|
118
|
+
for (const [n, c] of current) put(approved, n, { hash: c.hash, descriptor: c.descriptor, at, how: 'first-use' });
|
|
119
|
+
const rec = { server: { ...seen }, seen: { ...seen }, approved, pending: {} };
|
|
120
|
+
events.push({ kind: 'first-use', tools: [...current.keys()] });
|
|
121
|
+
return { record: rec, effective: [...current.values()].map((c) => c.tool), pending: [], events, changed: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const rec = { server: { ...record.server }, seen: { ...seen }, approved: {}, pending: {} };
|
|
125
|
+
for (const k of Object.keys(record.approved || {})) put(rec.approved, k, record.approved[k]);
|
|
126
|
+
const versionChanged = !!seen.version && !!record.server.version && seen.version !== record.server.version;
|
|
127
|
+
// A different server NAME is an identity change like a version change: approved (and reported)
|
|
128
|
+
// when not strict, pending under strictApproval. It is never a fresh first use - that let a
|
|
129
|
+
// server rename itself past strictApproval, or release a pending change by flipping its name.
|
|
130
|
+
const nameChanged = !!seen.name && !!record.server.name && seen.name !== record.server.name;
|
|
131
|
+
const autoApprove = (versionChanged || nameChanged) && !strict;
|
|
132
|
+
const effective = [];
|
|
133
|
+
const pending = [];
|
|
134
|
+
const upgraded = [];
|
|
135
|
+
for (const [n, c] of current) {
|
|
136
|
+
const a = own(rec.approved, n);
|
|
137
|
+
if (a && a.hash === c.hash) { effective.push(c.tool); continue; }
|
|
138
|
+
const reason = a ? 'changed' : 'new';
|
|
139
|
+
if (autoApprove) {
|
|
140
|
+
put(rec.approved, n, { hash: c.hash, descriptor: c.descriptor, at, how: 'upgrade' });
|
|
141
|
+
effective.push(c.tool);
|
|
142
|
+
upgraded.push({ tool: n, reason });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const prior = own(record.pending, n);
|
|
146
|
+
put(rec.pending, n, {
|
|
147
|
+
hash: c.hash,
|
|
148
|
+
descriptor: c.descriptor,
|
|
149
|
+
since: prior && prior.hash === c.hash && prior.since ? prior.since : at,
|
|
150
|
+
reason,
|
|
151
|
+
...(writer ? { by: writer } : {}),
|
|
152
|
+
});
|
|
153
|
+
pending.push({
|
|
154
|
+
tool: n,
|
|
155
|
+
hash: c.hash, // what an accept must match: the descriptor the user was shown, not a later one
|
|
156
|
+
reason,
|
|
157
|
+
approved: a ? clone(a.descriptor) : null,
|
|
158
|
+
current: clone(c.descriptor),
|
|
159
|
+
changed: changedFields(a && a.descriptor, c.descriptor),
|
|
160
|
+
});
|
|
161
|
+
if (a) effective.push(clone(a.descriptor)); // serve the APPROVED text; the name is unchanged
|
|
162
|
+
}
|
|
163
|
+
// The approved identity follows the server only while nothing is waiting: a pending change was
|
|
164
|
+
// seen at the new version, and accepting it (accept) is what moves the identity forward.
|
|
165
|
+
if (Object.keys(rec.pending).length === 0) {
|
|
166
|
+
if (seen.name) rec.server.name = seen.name;
|
|
167
|
+
if (seen.version) rec.server.version = seen.version;
|
|
168
|
+
}
|
|
169
|
+
if (upgraded.length && nameChanged) events.push({ kind: 'server-changed', from: record.server.name, to: seen.name, tools: upgraded });
|
|
170
|
+
else if (upgraded.length) events.push({ kind: 'upgrade', from: record.server.version, to: seen.version, tools: upgraded });
|
|
171
|
+
return { record: rec, effective, pending, events, changed: canonical(rec) !== before };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Several gateways can share one home, each with its own child of an upstream, so each can see a
|
|
176
|
+
* different listing. Before this process writes its record, carry over the pending entries in the
|
|
177
|
+
* STORED record that another process wrote: an entry this record neither holds as pending nor has
|
|
178
|
+
* approved at that hash, and that this process did not write itself (`ownWritten(tool, hash)`; its
|
|
179
|
+
* own entry that it no longer sees means its server reverted, so that one goes), and whose writer
|
|
180
|
+
* is still running (`isAlive(by)`; an entry left by a gateway that has exited - this gateway's own
|
|
181
|
+
* previous run, say - is no one's view any more, and kept it would never clear). One pending slot
|
|
182
|
+
* per tool: this process's own pending entry for a tool wins.
|
|
183
|
+
* @returns {object} a NEW record
|
|
184
|
+
*/
|
|
185
|
+
function keepForeignPending(stored, next, ownWritten, isAlive) {
|
|
186
|
+
const rec = clone(next);
|
|
187
|
+
if (!rec.pending || typeof rec.pending !== 'object') rec.pending = {};
|
|
188
|
+
const sp = stored && stored.pending && typeof stored.pending === 'object' ? stored.pending : {};
|
|
189
|
+
for (const n of Object.keys(sp)) {
|
|
190
|
+
const e = sp[n];
|
|
191
|
+
if (!e || typeof e.hash !== 'string' || hasOwn(rec.pending, n)) continue;
|
|
192
|
+
const a = own(rec.approved, n);
|
|
193
|
+
if (a && a.hash === e.hash) continue; // accepted since
|
|
194
|
+
if (typeof ownWritten === 'function' && ownWritten(n, e.hash)) continue;
|
|
195
|
+
if (e.by && typeof isAlive === 'function' && !isAlive(e.by)) continue;
|
|
196
|
+
put(rec.pending, n, clone(e));
|
|
197
|
+
}
|
|
198
|
+
return rec;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Accept pending descriptors: one tool, or every pending one when `toolName` is undefined.
|
|
203
|
+
* @returns {{ record: object, accepted: string[] }} a NEW record (the input is not modified)
|
|
204
|
+
*/
|
|
205
|
+
function accept(record, toolName, now) {
|
|
206
|
+
const at = typeof now === 'string' && now ? now : new Date().toISOString();
|
|
207
|
+
const rec = clone(record || {});
|
|
208
|
+
if (!rec.approved || typeof rec.approved !== 'object') rec.approved = {};
|
|
209
|
+
if (!rec.pending || typeof rec.pending !== 'object') rec.pending = {};
|
|
210
|
+
const names = toolName === undefined ? Object.keys(rec.pending) : (hasOwn(rec.pending, toolName) ? [toolName] : []);
|
|
211
|
+
for (const n of names) {
|
|
212
|
+
const p = rec.pending[n];
|
|
213
|
+
put(rec.approved, n, { hash: p.hash, descriptor: p.descriptor, at, how: 'accepted' });
|
|
214
|
+
delete rec.pending[n];
|
|
215
|
+
}
|
|
216
|
+
if (Object.keys(rec.pending).length === 0 && rec.seen && rec.server) {
|
|
217
|
+
if (rec.seen.name) rec.server.name = rec.seen.name;
|
|
218
|
+
if (rec.seen.version) rec.server.version = rec.seen.version;
|
|
219
|
+
}
|
|
220
|
+
return { record: rec, accepted: names };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── the state file ────────────────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
function emptyApprovals() {
|
|
226
|
+
return { version: FILE_VERSION, upstreams: {} };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
230
|
+
|
|
231
|
+
/** Throw, naming the file and the field, on anything that is not a well-formed approvals file. */
|
|
232
|
+
function validateApprovals(doc, filePath) {
|
|
233
|
+
const fail = (what) => { throw new Error(`tool approvals: ${filePath}: ${what}`); };
|
|
234
|
+
if (!isPlainObject(doc)) fail('must be a JSON object');
|
|
235
|
+
if (typeof doc.version !== 'number') fail('"version" must be a number');
|
|
236
|
+
if (!isPlainObject(doc.upstreams)) fail('"upstreams" must be an object keyed by upstream id');
|
|
237
|
+
for (const id of Object.keys(doc.upstreams)) {
|
|
238
|
+
const r = doc.upstreams[id];
|
|
239
|
+
const where = `upstream ${JSON.stringify(id)}`;
|
|
240
|
+
if (!isPlainObject(r)) fail(`${where} must be an object`);
|
|
241
|
+
if (!isPlainObject(r.server) || typeof r.server.name !== 'string' || typeof r.server.version !== 'string') {
|
|
242
|
+
fail(`${where}: "server" must be { name: string, version: string }`);
|
|
243
|
+
}
|
|
244
|
+
for (const part of ['approved', 'pending']) {
|
|
245
|
+
if (!isPlainObject(r[part])) fail(`${where}: "${part}" must be an object keyed by tool name`);
|
|
246
|
+
for (const n of Object.keys(r[part])) {
|
|
247
|
+
const e = r[part][n];
|
|
248
|
+
if (!isPlainObject(e) || typeof e.hash !== 'string' || !e.hash.startsWith('sha256:') || !isPlainObject(e.descriptor)) {
|
|
249
|
+
fail(`${where}: "${part}" entry ${JSON.stringify(n)} must be { hash: "sha256:...", descriptor: {...} }`);
|
|
250
|
+
}
|
|
251
|
+
if (e.descriptor.name !== n) fail(`${where}: "${part}" entry ${JSON.stringify(n)} has a descriptor named ${JSON.stringify(e.descriptor.name)}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Read the approvals file. Missing -> empty. Unreadable or malformed -> throw naming the file. */
|
|
258
|
+
function loadApprovals(filePath) {
|
|
259
|
+
let raw;
|
|
260
|
+
try {
|
|
261
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (err && err.code === 'ENOENT') return emptyApprovals();
|
|
264
|
+
throw new Error(`tool approvals: cannot read ${filePath}: ${(err && err.message) || String(err)}`);
|
|
265
|
+
}
|
|
266
|
+
let doc;
|
|
267
|
+
try {
|
|
268
|
+
doc = JSON.parse(raw);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
throw new Error(`tool approvals: ${filePath} is not valid JSON: ${(err && err.message) || String(err)}`);
|
|
271
|
+
}
|
|
272
|
+
validateApprovals(doc, filePath);
|
|
273
|
+
return doc;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Write the approvals file atomically (the shared temp + fsync + rename writer). */
|
|
277
|
+
function saveApprovals(filePath, doc) {
|
|
278
|
+
// A home without an mcp/ folder must still record first use (else nothing is ever remembered).
|
|
279
|
+
fs.mkdirSync(require('node:path').dirname(filePath), { recursive: true });
|
|
280
|
+
atomicWriteJson(filePath, doc);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The ONE accept every route shares - the tf_accept_change management tool, the CLI, the UI server
|
|
285
|
+
* and the gateway itself: read the CURRENT file, accept, write it back atomically. With
|
|
286
|
+
* `expectHash`, only if the pending descriptor is still the one the user was shown (a server that
|
|
287
|
+
* changed again is not approved sight unseen). Living in the engine, it reaches every install -
|
|
288
|
+
* including an external config home, whose copied scripts and register never refresh.
|
|
289
|
+
* Throws on an unreadable file.
|
|
290
|
+
* @returns {{ accepted: string[], pending: string[], stale?: true, unknown?: true }}
|
|
291
|
+
*/
|
|
292
|
+
function acceptInFile(filePath, upstreamId, toolName, expectHash, now) {
|
|
293
|
+
const doc = loadApprovals(filePath);
|
|
294
|
+
const rec = getRecord(doc, upstreamId);
|
|
295
|
+
if (!rec) return { accepted: [], pending: [], unknown: true };
|
|
296
|
+
if (expectHash !== undefined && expectHash !== null) {
|
|
297
|
+
const p = toolName !== undefined ? own(rec.pending, toolName) : undefined;
|
|
298
|
+
if (!p || p.hash !== expectHash) return { accepted: [], pending: Object.keys(rec.pending || {}), stale: true };
|
|
299
|
+
}
|
|
300
|
+
const res = accept(rec, toolName, now);
|
|
301
|
+
if (res.accepted.length) {
|
|
302
|
+
setRecord(doc, upstreamId, res.record);
|
|
303
|
+
saveApprovals(filePath, doc);
|
|
304
|
+
}
|
|
305
|
+
return { accepted: res.accepted, pending: Object.keys(res.record.pending || {}) };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Every change waiting for acceptance in a loaded doc, flat - the one view the CLI (`changes`) and
|
|
310
|
+
* the UI (/api/tool-changes) both render. Descriptions are the SERVER's text: render as text only.
|
|
311
|
+
* `fields` holds { approved, new } for every other changed field (a schema's parameter descriptions
|
|
312
|
+
* reach the model too, so the user must be able to read them before accepting).
|
|
313
|
+
* @returns {Array<{upstream, tool, reason, since, hash, approvedDescription, newDescription, changed, fields}>}
|
|
314
|
+
*/
|
|
315
|
+
function listPending(doc) {
|
|
316
|
+
const out = [];
|
|
317
|
+
const ups = (doc && doc.upstreams) || {};
|
|
318
|
+
for (const id of Object.keys(ups)) {
|
|
319
|
+
const rec = ups[id] || {};
|
|
320
|
+
const pend = rec.pending || {};
|
|
321
|
+
for (const name of Object.keys(pend)) {
|
|
322
|
+
const p = pend[name] || {};
|
|
323
|
+
const a = own(rec.approved, name);
|
|
324
|
+
const changed = changedFields(a && a.descriptor, p.descriptor);
|
|
325
|
+
out.push({
|
|
326
|
+
upstream: id,
|
|
327
|
+
tool: name,
|
|
328
|
+
reason: p.reason || (a ? 'changed' : 'new'),
|
|
329
|
+
since: p.since || null,
|
|
330
|
+
hash: p.hash,
|
|
331
|
+
approvedDescription: a && a.descriptor && typeof a.descriptor.description === 'string' ? a.descriptor.description : null,
|
|
332
|
+
newDescription: p.descriptor && typeof p.descriptor.description === 'string' ? p.descriptor.description : null,
|
|
333
|
+
changed,
|
|
334
|
+
fields: Object.fromEntries(changed.filter((f) => f !== 'name' && f !== 'description')
|
|
335
|
+
.map((f) => [f, { approved: a ? own(a.descriptor, f) ?? null : null, new: own(p.descriptor, f) ?? null }])),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Tools approved automatically on a server version upgrade, latest first (at most `limit`). */
|
|
343
|
+
function listUpgrades(doc, limit) {
|
|
344
|
+
const out = [];
|
|
345
|
+
const ups = (doc && doc.upstreams) || {};
|
|
346
|
+
for (const id of Object.keys(ups)) {
|
|
347
|
+
const appr = (ups[id] && ups[id].approved) || {};
|
|
348
|
+
for (const name of Object.keys(appr)) {
|
|
349
|
+
if (appr[name] && appr[name].how === 'upgrade') out.push({ upstream: id, tool: name, at: appr[name].at || null });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
out.sort((x, y) => String(y.at).localeCompare(String(x.at)));
|
|
353
|
+
return typeof limit === 'number' ? out.slice(0, limit) : out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Why an acceptInFile result accepted nothing (the wording every route shares), or null when it did. */
|
|
357
|
+
function acceptFailure(res, upstreamId, toolName) {
|
|
358
|
+
if (res && res.unknown) return `no tool approvals are recorded for upstream "${upstreamId}"`;
|
|
359
|
+
if (res && res.stale) return 'that change is no longer the one pending - the server changed the tool again';
|
|
360
|
+
if (!res || !res.accepted || res.accepted.length === 0) {
|
|
361
|
+
return toolName ? `tool "${toolName}" has no change waiting on upstream "${upstreamId}"` : `upstream "${upstreamId}" has no changes waiting`;
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** One upstream's record from a loaded doc (own property only), or undefined. */
|
|
367
|
+
function getRecord(doc, upstreamId) {
|
|
368
|
+
return own(doc && doc.upstreams, upstreamId);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Set one upstream's record on a loaded doc (an own property, whatever the id). */
|
|
372
|
+
function setRecord(doc, upstreamId, record) {
|
|
373
|
+
put(doc.upstreams, upstreamId, record);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
module.exports = {
|
|
377
|
+
HASHED_FIELDS,
|
|
378
|
+
canonical,
|
|
379
|
+
descriptorOf,
|
|
380
|
+
hashTool,
|
|
381
|
+
changedFields,
|
|
382
|
+
printable,
|
|
383
|
+
evaluate,
|
|
384
|
+
keepForeignPending,
|
|
385
|
+
accept,
|
|
386
|
+
emptyApprovals,
|
|
387
|
+
validateApprovals,
|
|
388
|
+
loadApprovals,
|
|
389
|
+
saveApprovals,
|
|
390
|
+
getRecord,
|
|
391
|
+
setRecord,
|
|
392
|
+
acceptInFile,
|
|
393
|
+
listPending,
|
|
394
|
+
listUpgrades,
|
|
395
|
+
acceptFailure,
|
|
396
|
+
};
|