toolfunnel 0.7.1 → 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.
@@ -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
+ };
@@ -41,11 +41,12 @@ const fs = require('node:fs');
41
41
  const path = require('node:path');
42
42
  const { spawn, execFile } = require('node:child_process');
43
43
  const reaper = require('../core/child-reaper');
44
- const { migrateForStoreFile } = require('../core/rename-migration');
44
+ const { takeWithinCap } = require('../core/output-cap');
45
+ const { migrateForStoreFile, snapshotForStoreFile, restoreSnapshot } = require('../core/rename-migration');
45
46
 
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
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
49
50
  // matches the upstream PAYLOAD window so a slow-but-alive local tool behaves like a slow upstream.
50
51
  // Test seam: the suite proves the timeout/cap BEHAVIOUR (not just these names) by shrinking the
51
52
  // windows via env; production never sets them.
@@ -106,61 +107,9 @@ function cloneEntry(entry) {
106
107
  return JSON.parse(JSON.stringify(entry));
107
108
  }
108
109
 
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
- }
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');
164
113
 
165
114
  /** Validate a register entry shape; throws with a clear message on a bad shape. */
166
115
  function validateEntry(entry, { requireId = true } = {}) {
@@ -245,12 +194,11 @@ function defaultRunScript(scriptsRoot, invoke, args) {
245
194
  let stdout = '';
246
195
  let stderr = '';
247
196
  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.
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.
254
202
  const finish = (result) => {
255
203
  if (settled) return;
256
204
  settled = true;
@@ -286,8 +234,10 @@ function defaultRunScript(scriptsRoot, invoke, args) {
286
234
  let errBytes = 0;
287
235
  child.stdout.on('data', (d) => {
288
236
  if (outBytes >= MAX_TOOL_OUTPUT_BYTES) return;
289
- stdout += d;
290
- outBytes += Buffer.byteLength(d, 'utf8');
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
291
241
  if (outBytes >= MAX_TOOL_OUTPUT_BYTES) {
292
242
  killTree(child);
293
243
  finish({ ok: false, code: null, stdout, stderr: stderr + '\nRegistry: tool output exceeded the cap and was terminated', outputCapped: true });
@@ -295,8 +245,10 @@ function defaultRunScript(scriptsRoot, invoke, args) {
295
245
  });
296
246
  child.stderr.on('data', (d) => {
297
247
  if (errBytes >= MAX_TOOL_OUTPUT_BYTES) return;
298
- stderr += d;
299
- errBytes += Buffer.byteLength(d, 'utf8');
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]';
300
252
  });
301
253
  child.on('error', (err) => {
302
254
  if (settled) return;
@@ -419,11 +371,21 @@ class Registry {
419
371
  // in rename-migration.js). Runs BEFORE persist so a refusal leaves the register untouched.
420
372
  const oldSurfaced = existing.name || id;
421
373
  const newSurfaced = merged.name || id;
374
+ const snap = oldSurfaced !== newSurfaced ? snapshotForStoreFile(this._filePath) : null;
422
375
  if (oldSurfaced !== newSurfaced) {
423
376
  migrateForStoreFile(this._filePath, oldSurfaced, newSurfaced, { migrateState: false });
424
377
  }
425
378
  this._byId.set(id, cloneEntry(merged));
426
- this._persist();
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
+ }
427
389
  return this.getEntry(id);
428
390
  }
429
391
 
@@ -498,23 +460,7 @@ class Registry {
498
460
  throw new Error(`Registry.writeScript: script path escapes scripts root ("${invokePath}")`);
499
461
  }
500
462
  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
- }
463
+ atomicWriteText(resolved, text); // bytes as given (no line-ending normalisation), temp + rename
518
464
  return resolved;
519
465
  }
520
466
 
@@ -24,7 +24,7 @@
24
24
  *
25
25
  * Shape of tools.state.json: { "<id>": { "enabled": false, "hidden": true, "hot": true }, ... }
26
26
  *
27
- * PASSTHROUGH (0.6.0): one RESERVED top-level key, `"passthrough": "<upstreamId>"` (a STRING,
27
+ * PASSTHROUGH: one RESERVED top-level key, `"passthrough": "<upstreamId>"` (a STRING,
28
28
  * unambiguous against the per-tool object entries). When set, the gateway becomes a transparent
29
29
  * wrapper for that one upstream: its tools ARE the advertised surface (real schemas, implicitly
30
30
  * hot), the meta-tools and everything else are hidden and uncallable, and every call still fires
@@ -35,6 +35,15 @@
35
35
  return node;
36
36
  }
37
37
 
38
+ // A server's text shown with its control and bidi characters visible, so it cannot reorder a line.
39
+ // Same escapes as the engine's printable(): a newline HTML would collapse stays visible.
40
+ var NAMED_ESCAPES = { '\n': '\\n', '\r': '\\r', '\t': '\\t' };
41
+ function printable(s) {
42
+ return String(s).replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, function (c) {
43
+ return NAMED_ESCAPES[c] || '\\u' + ('000' + c.charCodeAt(0).toString(16)).slice(-4);
44
+ });
45
+ }
46
+
38
47
  // ── fetch wrappers ──────────────────────────────────────────────────────────
39
48
  function getJson(url) {
40
49
  return fetch(url, { headers: { Accept: 'application/json' } }).then(function (r) { return r.json(); });
@@ -503,7 +512,7 @@
503
512
  function () { input.checked = !want; });
504
513
  });
505
514
 
506
- // legacyPin (0.6.0): keep speaking MCP 2024-11-05 to this upstream forever - never auto-
515
+ // legacyPin: keep speaking MCP 2024-11-05 to this upstream forever - never auto-
507
516
  // upgrades, warns at startup and per call. Opt-in; takes effect on the next (re)connect.
508
517
  var pinSwitch = makeSwitch(u.legacyPin === true, true, function (input) {
509
518
  var want = input.checked;
@@ -656,7 +665,57 @@
656
665
  list.parentNode.insertBefore(banner, list);
657
666
  }
658
667
 
668
+ // Tool descriptor changes held back for acceptance. Descriptions are the SERVER's own,
669
+ // unverified text: rendered with textContent only (el's `text`), never as HTML.
670
+ function renderToolChanges(data) {
671
+ var existing = document.getElementById('tool-changes');
672
+ if (existing) existing.remove();
673
+ var list = $('#mcp-list');
674
+ if (!list || !list.parentNode) return;
675
+ if (data && data.ok === false) {
676
+ list.parentNode.insertBefore(el('div', { id: 'tool-changes', class: 'row' }, [
677
+ el('p', { text: 'TOOL APPROVALS UNREADABLE: ' + (data.error || 'unknown error') + ' - the gateway withholds the ' +
678
+ 'affected tools until the file is repaired (removing it, or an upstream\'s entry, approves the current tools as first use).' }),
679
+ ]), list);
680
+ return;
681
+ }
682
+ var pending = (data && Array.isArray(data.pending)) ? data.pending : [];
683
+ if (!pending.length) return;
684
+ var box = el('div', { id: 'tool-changes' }, [
685
+ el('p', { text: pending.length + ' tool change(s) held back: the server changed what these tools say without ' +
686
+ 'changing its version (or strictApproval is on). Until you accept, the gateway keeps showing the approved text ' +
687
+ 'and refuses calls to them. Accept only if you recognise the change.' }),
688
+ ]);
689
+ pending.forEach(function (p) {
690
+ var acceptBtn = el('button', { class: 'btn btn-sm', type: 'button' }, ['Accept']);
691
+ acceptBtn.addEventListener('click', function () {
692
+ acceptBtn.disabled = true;
693
+ postJson('/api/tool-changes/accept', { upstream: p.upstream, tool: p.tool, hash: p.hash }).then(function (res) {
694
+ acceptBtn.disabled = false;
695
+ if (!res || res.ok !== true) { toast((res && res.error) || 'Accept failed', 'error'); loadToolChanges(); return; }
696
+ toast('Accepted ' + p.upstream + ' / ' + printable(p.tool), 'good');
697
+ loadToolChanges();
698
+ }).catch(function (e) { acceptBtn.disabled = false; toast('Request failed: ' + errText(e), 'error'); });
699
+ });
700
+ box.appendChild(el('div', { class: 'row' }, [
701
+ el('p', { text: p.upstream + ' / ' + printable(p.tool) + ' (' + (p.reason === 'new' ? 'new tool' : 'changed: ' + p.changed.join(', ')) + ')' }),
702
+ el('p', { class: 'muted', text: 'Approved: ' + (p.approvedDescription == null ? '(none - a new tool)' : printable(p.approvedDescription)) }),
703
+ el('p', { text: 'New (the server\'s text): ' + (p.newDescription == null ? '(no description)' : printable(p.newDescription)) }),
704
+ el('div', {}, Object.keys(p.fields || {}).map(function (f) {
705
+ return el('p', { style: 'font-family: var(--mono)', text: 'New ' + f + ': ' + printable(JSON.stringify(p.fields[f].new)) });
706
+ })),
707
+ acceptBtn,
708
+ ]));
709
+ });
710
+ list.parentNode.insertBefore(box, list);
711
+ }
712
+
713
+ function loadToolChanges() {
714
+ return getJson('/api/tool-changes').then(renderToolChanges).catch(function () { /* the list below still loads */ });
715
+ }
716
+
659
717
  function loadMcps() {
718
+ loadToolChanges();
660
719
  return getJson('/api/upstreams').then(function (data) {
661
720
  upstreams = (data && Array.isArray(data.upstreams)) ? data.upstreams : [];
662
721
  var expose = (data && Array.isArray(data.expose)) ? data.expose : [];