toolfunnel 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +97 -18
- package/docs/KNOWN_BUGS.md +71 -90
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +24 -10
- package/hooks/hooks.manifest.json +1 -1
- package/hooks/scripts/example-deny-dangerous.js +12 -4
- package/mcp/servers/mock-upstream/server.js +36 -6
- package/package.json +67 -65
- package/src/auth/config.js +2 -2
- package/src/auth/resource-server.js +12 -3
- package/src/core/child-reaper.js +61 -0
- package/src/core/events.js +7 -7
- package/src/core/hook-engine.js +85 -31
- package/src/core/hook-loader.js +46 -13
- package/src/core/hook-runner.js +258 -38
- package/src/core/logger.js +138 -12
- package/src/core/matcher.js +59 -10
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +1 -1
- package/src/extend/add-hook.md +2 -2
- package/src/extend/add-mcp.md +3 -6
- package/src/extend/configure.md +2 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/howto.js +3 -3
- package/src/extend/package.md +11 -8
- package/src/mcp/aggregator.js +480 -52
- package/src/mcp/expose-store.js +130 -13
- package/src/mcp/gated-run.js +26 -17
- package/src/mcp/http-transport.js +274 -22
- package/src/mcp/mcp-client.js +194 -31
- package/src/mcp/modern.js +44 -10
- package/src/mcp/server.js +513 -89
- package/src/tools/registry.js +172 -24
- package/src/ui/public/app.js +17 -4
- package/src/ui/server.js +297 -29
- package/tools/scripts/tf-hook-set.js +11 -3
- package/tools/tools.register.json +227 -227
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
package/src/ui/server.js
CHANGED
|
@@ -39,7 +39,7 @@ const { loadRegistry, atomicWriteJson, resolveMode } = require('../tools/registr
|
|
|
39
39
|
const { loadToolState, isToolEnabled, isToolHidden, isToolHot, setToolEnabled, setToolHidden, setToolHot, clearToolState, getPassthrough, setPassthrough } = require('../tools/tool-state');
|
|
40
40
|
const { loadExposeStore } = require('../mcp/expose-store');
|
|
41
41
|
const { Aggregator } = require('../mcp/aggregator');
|
|
42
|
-
const { loadManifest } = require('../core/hook-loader');
|
|
42
|
+
const { loadManifest, normalizeState } = require('../core/hook-loader');
|
|
43
43
|
const { matches } = require('../core/matcher');
|
|
44
44
|
const { META_TOOLS } = require('../mcp/protocol');
|
|
45
45
|
const logger = require('../core/logger');
|
|
@@ -220,6 +220,7 @@ function createUiServer(opts = {}) {
|
|
|
220
220
|
const TOOL_STATE_PATH = path.join(root, 'tools', 'tools.state.json');
|
|
221
221
|
const EXPOSE_PATH = path.join(root, 'mcp', 'expose.json');
|
|
222
222
|
const MANIFEST_PATH = path.join(root, 'hooks', 'hooks.manifest.json');
|
|
223
|
+
const HOOK_STATE_PATH = path.join(root, 'hooks', 'hooks.state.json');
|
|
223
224
|
const SCRIPTS_ROOT = path.join(root, 'tools', 'scripts');
|
|
224
225
|
// Where a Pre/Post hook script for a tool must be authored. The manifest command carries the
|
|
225
226
|
// PORTABLE ${HOOKS_DIR} token (expanded by the hook-loader at load); this is its real on-disk dir.
|
|
@@ -245,7 +246,11 @@ function createUiServer(opts = {}) {
|
|
|
245
246
|
try {
|
|
246
247
|
const d = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
247
248
|
if (d && typeof d === 'object' && !Array.isArray(d)) {
|
|
249
|
+
// Carry the RAW object and normalise the two known keys onto it: the toggle
|
|
250
|
+
// write-sites persist this very object, so rebuilding {version, hooks} from
|
|
251
|
+
// scratch silently dropped every other top-level key on the first toggle.
|
|
248
252
|
return {
|
|
253
|
+
...d,
|
|
249
254
|
version: typeof d.version === 'number' ? d.version : 1,
|
|
250
255
|
hooks: Array.isArray(d.hooks) ? d.hooks : [],
|
|
251
256
|
};
|
|
@@ -256,6 +261,38 @@ function createUiServer(opts = {}) {
|
|
|
256
261
|
return { version: 1, hooks: [] };
|
|
257
262
|
}
|
|
258
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Read the hooks.state.json enabled OVERLAY (hook id -> boolean) defensively. Never throws.
|
|
266
|
+
* Coerced through the SAME normalizeState the gateway's hook-loader applies (flat map, or the
|
|
267
|
+
* wrapped { enabled: {...} } form it tolerates) - reading the raw parse here let the two split:
|
|
268
|
+
* a wrapped file read as "no overlay" on this side while the gateway obeyed it, and a
|
|
269
|
+
* merge-write appended a flat key beside a wrapper that kept winning at load. Writing the
|
|
270
|
+
* normalised map back (reconcileOverlay) converges any wrapped file to flat on first toggle.
|
|
271
|
+
*/
|
|
272
|
+
function readHookOverlay() {
|
|
273
|
+
try {
|
|
274
|
+
return normalizeState(JSON.parse(fs.readFileSync(HOOK_STATE_PATH, 'utf8')));
|
|
275
|
+
} catch (_e) { /* missing/malformed -> no overlay */ }
|
|
276
|
+
return {};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The manifest rows with the overlay APPLIED - the enabled-state the GATEWAY actually loads.
|
|
281
|
+
* hook-loader applyStateOverlay(): for any row with a string id the overlay has a key for, the
|
|
282
|
+
* overlay WINS (documented precedence). There are two stores and two writers; every STATE read
|
|
283
|
+
* in this file must go through this view, because reading the raw manifest is how the Tools-tab
|
|
284
|
+
* switch showed a gate ON while the gateway ran nothing. Commands stay RAW (${HOOKS_DIR}
|
|
285
|
+
* unexpanded) - this is a state view, not an execution view, so identity comparisons against
|
|
286
|
+
* authored command strings keep working.
|
|
287
|
+
*/
|
|
288
|
+
function effectiveHooks() {
|
|
289
|
+
const overlay = readHookOverlay();
|
|
290
|
+
return readManifest().hooks.map((h) => {
|
|
291
|
+
if (!h || typeof h.id !== 'string' || !Object.prototype.hasOwnProperty.call(overlay, h.id)) return h;
|
|
292
|
+
return { ...h, enabled: overlay[h.id] === true };
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
259
296
|
/** Does any ENABLED manifest hook for `event` fire for `gateName`? (matcher.matches semantics) */
|
|
260
297
|
function hookFires(hooks, event, gateName) {
|
|
261
298
|
for (const h of hooks) {
|
|
@@ -274,7 +311,7 @@ function createUiServer(opts = {}) {
|
|
|
274
311
|
function apiTools() {
|
|
275
312
|
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
276
313
|
const state = loadToolState(TOOL_STATE_PATH);
|
|
277
|
-
const hooks =
|
|
314
|
+
const hooks = effectiveHooks();
|
|
278
315
|
return registry.list().map((b) => {
|
|
279
316
|
const gateName = b.name || b.id;
|
|
280
317
|
return {
|
|
@@ -295,6 +332,18 @@ function createUiServer(opts = {}) {
|
|
|
295
332
|
hot: isToolHot(state, b.id, false),
|
|
296
333
|
pre: hookFires(hooks, 'PreToolUse', gateName),
|
|
297
334
|
post: hookFires(hooks, 'PostToolUse', gateName),
|
|
335
|
+
// The per-tool switch's OWN entry as distinct from "some broader hook fires". Without the
|
|
336
|
+
// split the client cannot tell "this tool has its own gate" from "a wildcard covers it",
|
|
337
|
+
// which is how the OFF toast lied and the switch snapped back on refresh. Identity is
|
|
338
|
+
// (event, escaped literal, COMMAND) - the same triple apiSetHook owns. Matching on the
|
|
339
|
+
// matcher alone read TRUE for a FOREIGN entry sitting on this tool's name, which is what
|
|
340
|
+
// made the switch appear correct after arming somebody else's hook.
|
|
341
|
+
preOwn: hooks.some((h) => h && h.event === 'PreToolUse' && h.enabled === true
|
|
342
|
+
&& h.matcher === escapeRegex(gateName)
|
|
343
|
+
&& h.command === 'node "${HOOKS_DIR}/scripts/' + b.id + '-pretooluse.js"'),
|
|
344
|
+
postOwn: hooks.some((h) => h && h.event === 'PostToolUse' && h.enabled === true
|
|
345
|
+
&& h.matcher === escapeRegex(gateName)
|
|
346
|
+
&& h.command === 'node "${HOOKS_DIR}/scripts/' + b.id + '-posttooluse.js"'),
|
|
298
347
|
};
|
|
299
348
|
});
|
|
300
349
|
}
|
|
@@ -405,21 +454,156 @@ function createUiServer(opts = {}) {
|
|
|
405
454
|
}
|
|
406
455
|
const gateName = gateNameFor(registry.getEntry(id));
|
|
407
456
|
|
|
457
|
+
// The id becomes a FILENAME and is interpolated into a manifest command that hook-runner
|
|
458
|
+
// spawns with shell:true, so it must be filename-safe (0.7.0). The register only requires a
|
|
459
|
+
// non-empty string, and an id carrying a quote produced a command that either could not run -
|
|
460
|
+
// while the UI still reported "Pre gate enabled", i.e. a dead gate presented as a live one -
|
|
461
|
+
// or carried arbitrary shell text into a file the operator reads as policy. Same character
|
|
462
|
+
// class deriveHookScriptRel already accepts.
|
|
463
|
+
if (!/^[A-Za-z0-9._-]+$/.test(id)) {
|
|
464
|
+
return {
|
|
465
|
+
status: 400,
|
|
466
|
+
json: {
|
|
467
|
+
ok: false,
|
|
468
|
+
error: `tool id "${id}" cannot carry a per-tool gate: an id used as a hook script name must match [A-Za-z0-9._-]+. Rename the tool, or add the hook by hand on the Hooks tab.`,
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
408
473
|
const manifest = readManifest();
|
|
409
474
|
const hooks = manifest.hooks;
|
|
475
|
+
// EFFECTIVE enabled for a row: the hooks.state.json overlay (keyed by id) WINS wherever it
|
|
476
|
+
// has a key - that is what the gateway loads (hook-loader applyStateOverlay). Deciding from
|
|
477
|
+
// the raw manifest flag let the two stores disagree: manifest ON + overlay OFF read as
|
|
478
|
+
// "already on" here while the gateway ran no gate at all.
|
|
479
|
+
const overlay = readHookOverlay();
|
|
480
|
+
const effectiveEnabled = (h) => (h && typeof h.id === 'string' && h.id.length > 0
|
|
481
|
+
&& Object.prototype.hasOwnProperty.call(overlay, h.id))
|
|
482
|
+
? overlay[h.id] === true : !!(h && h.enabled === true);
|
|
483
|
+
// Mirror setEnabled's contract whenever this API changes the state of a row that HAS an id:
|
|
484
|
+
// the overlay is the precedence-winning store, so it must move with the manifest write (or,
|
|
485
|
+
// for a removed row, lose its key - a stray key pre-arms a future hook adopting the id).
|
|
486
|
+
const reconcileOverlay = (h, desired) => {
|
|
487
|
+
if (!h || typeof h.id !== 'string' || h.id.length === 0) return;
|
|
488
|
+
if (desired === null) delete overlay[h.id];
|
|
489
|
+
else overlay[h.id] = desired;
|
|
490
|
+
atomicWriteJson(HOOK_STATE_PATH, overlay);
|
|
491
|
+
};
|
|
410
492
|
const eventLower = event.toLowerCase();
|
|
411
493
|
const scriptName = `${id}-${eventLower}.js`;
|
|
412
494
|
const scriptPath = path.join(HOOK_SCRIPTS_DIR, scriptName);
|
|
413
495
|
|
|
496
|
+
// BOTH branches own exactly one shape: matcher === escapeRegex(gateName), this tool's
|
|
497
|
+
// escaped literal. Never the RAW name: the register only requires `name` to be a non-empty
|
|
498
|
+
// string, so a tool NAMED '*' (or string-equal to any broader matcher) would make a raw-name
|
|
499
|
+
// comparison a deletion primitive against the operator's global gate - the 0.7.0 hole
|
|
500
|
+
// reopened through the name field. When the name has no regex specials the
|
|
501
|
+
// escaped literal IS the raw name, so nothing legitimate is lost.
|
|
502
|
+
const literal = escapeRegex(gateName);
|
|
503
|
+
// The per-tool entry is identified by (event, matcher, COMMAND) - all three. Matching on
|
|
504
|
+
// (event, matcher) alone let this branch ADOPT an entry whose command runs a different
|
|
505
|
+
// script (hand-authored on the Hooks tab, or left by a tool that previously held this name)
|
|
506
|
+
// and then answer with THIS tool's invented `<id>-<event>.js` path plus "create it here".
|
|
507
|
+
// The operator writes their policy into a file no manifest command references: a dead gate
|
|
508
|
+
// presented as a live one. The command is what the gateway actually runs, so it is what
|
|
509
|
+
// identity has to be built on.
|
|
510
|
+
const ourCommand = 'node "${HOOKS_DIR}/scripts/' + scriptName + '"';
|
|
511
|
+
const isOwnLiteral = (h) => h && h.event === event && h.matcher === literal
|
|
512
|
+
&& h.command === ourCommand;
|
|
513
|
+
// Same gate, DIFFERENT command - somebody else's entry sitting on this tool's matcher.
|
|
514
|
+
const isForeignAtLiteral = (h) => h && h.event === event && h.matcher === literal
|
|
515
|
+
&& h.command !== ourCommand;
|
|
516
|
+
|
|
414
517
|
if (on) {
|
|
415
|
-
//
|
|
416
|
-
|
|
518
|
+
// GATE IDENTITY MUST BE UNAMBIGUOUS (0.7.0). gateNameFor is `name || id` and the register
|
|
519
|
+
// never constrains `name`, so two DIFFERENT tools can resolve to ONE matcher, and ON would
|
|
520
|
+
// hand the second tool a gate it does not own. Ownership resolves by ID, which the register
|
|
521
|
+
// guarantees unique: the tool whose *id* IS the gate name is the canonical owner and keeps
|
|
522
|
+
// its switch; any OTHER tool reaching the same name through its `name` field is refused
|
|
523
|
+
// rather than silently sharing. When no tool owns the name by id, every claimant is
|
|
524
|
+
// refused - genuinely ambiguous, so nothing is guessed.
|
|
525
|
+
//
|
|
526
|
+
// ON-ONLY, deliberately. OFF removes this tool's own (event, matcher, COMMAND) entry, which
|
|
527
|
+
// is unambiguous whatever the register says today - an own literal can predate a rival's
|
|
528
|
+
// arrival in the register, and refusing OFF then leaves the operator a live gate with no
|
|
529
|
+
// control that can disarm it. Ambiguity gates authoring, never removal.
|
|
530
|
+
const rivals = registry.list()
|
|
531
|
+
.filter((e) => e && e.id !== id && gateNameFor(e) === gateName)
|
|
532
|
+
.map((e) => e.id);
|
|
533
|
+
if (rivals.length > 0 && id !== gateName) {
|
|
534
|
+
return {
|
|
535
|
+
status: 409,
|
|
536
|
+
json: {
|
|
537
|
+
ok: false,
|
|
538
|
+
error: `tool "${id}" cannot own a per-tool gate: its gate name "${gateName}" is also `
|
|
539
|
+
+ `used by ${rivals.map((r) => `"${r}"`).join(', ')}, so one switch would silently own `
|
|
540
|
+
+ 'another tool\'s gate. Give this tool a distinct register `name`, or add the hook by '
|
|
541
|
+
+ 'hand on the Hooks tab with a matcher that targets exactly what you mean.',
|
|
542
|
+
gateName,
|
|
543
|
+
conflictsWith: rivals,
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Author (or re-enable) this tool's OWN literal entry. "Some enabled hook fires for this
|
|
549
|
+
// tool" is NOT "this tool's gate exists": with a global '*' gate present the old check
|
|
550
|
+
// wrote nothing, reported ok, and told the operator to create a script that no manifest
|
|
551
|
+
// command would ever reference - a dead gate presented as a live one.
|
|
552
|
+
const own = hooks.find(isOwnLiteral);
|
|
553
|
+
if (own) {
|
|
554
|
+
if (effectiveEnabled(own) !== true) {
|
|
555
|
+
own.enabled = true;
|
|
556
|
+
manifest.hooks = hooks;
|
|
557
|
+
atomicWriteJson(MANIFEST_PATH, manifest);
|
|
558
|
+
reconcileOverlay(own, true);
|
|
559
|
+
}
|
|
560
|
+
} else {
|
|
561
|
+
// Before authoring: is another command already wired to this exact gate? If so, that
|
|
562
|
+
// command is what runs. Enable it if the operator asked for the gate ON, but report ITS
|
|
563
|
+
// script - answering with our per-id path here is what turned a live gate into a lie.
|
|
564
|
+
const foreign = hooks.find(isForeignAtLiteral);
|
|
565
|
+
if (foreign) {
|
|
566
|
+
const rel = deriveHookScriptRel(foreign.command);
|
|
567
|
+
if (effectiveEnabled(foreign) !== true) {
|
|
568
|
+
// A DISABLED entry records an operator's decision, made where they made it. Arming it
|
|
569
|
+
// from here ran somebody else's policy under this switch's name - and OFF (own-literal
|
|
570
|
+
// only, by design) could never turn it back off: a one-way arming device for a hook
|
|
571
|
+
// the operator deliberately switched off. Refuse and point at the real entry instead;
|
|
572
|
+
// nothing is mutated.
|
|
573
|
+
return {
|
|
574
|
+
status: 409,
|
|
575
|
+
json: {
|
|
576
|
+
ok: false,
|
|
577
|
+
error: `A ${event} hook for "${gateName}" already exists but is DISABLED: its `
|
|
578
|
+
+ `command runs ${rel || foreign.command}, not a per-tool script authored here. `
|
|
579
|
+
+ 'This switch will not arm a hook that was deliberately switched off. '
|
|
580
|
+
+ 'Re-enable or remove that entry on the Hooks tab, then use this switch.',
|
|
581
|
+
command: foreign.command,
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
status: 200,
|
|
587
|
+
json: {
|
|
588
|
+
ok: true,
|
|
589
|
+
authored: false,
|
|
590
|
+
command: foreign.command,
|
|
591
|
+
// The path the WIRED command actually runs (null when it names no scripts/ file -
|
|
592
|
+
// then the command itself is the only honest answer).
|
|
593
|
+
scriptPath: rel ? path.join(HOOK_SCRIPTS_DIR, '..', rel) : null,
|
|
594
|
+
note:
|
|
595
|
+
`A ${event} hook for "${gateName}" already existed and was left as-is: its command `
|
|
596
|
+
+ `runs ${rel || foreign.command}, not a per-tool script authored here. Edit that `
|
|
597
|
+
+ 'script to change the behaviour, or manage the entry on the Hooks tab.',
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
}
|
|
417
601
|
hooks.push({
|
|
418
602
|
event,
|
|
419
|
-
matcher:
|
|
603
|
+
matcher: literal,
|
|
420
604
|
// The command carries the PORTABLE ${HOOKS_DIR} token (NOT interpolated here) - the
|
|
421
605
|
// hook-loader expands it to the absolute hooks dir at load time.
|
|
422
|
-
command:
|
|
606
|
+
command: ourCommand,
|
|
423
607
|
enabled: true,
|
|
424
608
|
});
|
|
425
609
|
manifest.hooks = hooks;
|
|
@@ -432,18 +616,39 @@ function createUiServer(opts = {}) {
|
|
|
432
616
|
scriptPath,
|
|
433
617
|
note:
|
|
434
618
|
`Create ${scriptName} at this path to define the ${event} behaviour. ` +
|
|
435
|
-
'Until the script exists the gate is configured but has no script to run
|
|
619
|
+
'Until the script exists the gate is configured but has no script to run - a missing ' +
|
|
620
|
+
'hook script is a non-blocking error, so calls PASS UNGATED until you write it. ' +
|
|
621
|
+
'Deny with exit 2 (a bare non-zero allows).',
|
|
436
622
|
},
|
|
437
623
|
};
|
|
438
624
|
}
|
|
439
625
|
|
|
440
|
-
// OFF: drop
|
|
441
|
-
|
|
442
|
-
|
|
626
|
+
// OFF: drop ONLY the per-tool LITERAL entry the ON branch authors - never a hook that
|
|
627
|
+
// merely ALSO fires for this tool (0.7.0 deleted the global wildcard gate here).
|
|
628
|
+
const next = hooks.filter((h) => !isOwnLiteral(h));
|
|
629
|
+
const removed = hooks.length - next.length;
|
|
630
|
+
if (removed > 0) {
|
|
443
631
|
manifest.hooks = next;
|
|
444
632
|
atomicWriteJson(MANIFEST_PATH, manifest);
|
|
633
|
+
// Removed rows lose their overlay key too - strays are not inert (applyState lays the
|
|
634
|
+
// overlay over the manifest at every load, so a future hook adopting the id would boot in
|
|
635
|
+
// the stale recorded state).
|
|
636
|
+
for (const h of hooks) if (isOwnLiteral(h)) reconcileOverlay(h, null);
|
|
445
637
|
}
|
|
446
|
-
|
|
638
|
+
// A broader hook (wildcard or regex) may STILL gate this tool. Say so: a bare {ok:true} reads
|
|
639
|
+
// as "this tool is now ungated" when the call changed nothing about that. Judged on EFFECTIVE
|
|
640
|
+
// state - a manifest-enabled row the overlay disables does not gate anything.
|
|
641
|
+
const stillGating = next
|
|
642
|
+
.filter((h) => h && h.event === event && effectiveEnabled(h) === true && matches(h.matcher, gateName))
|
|
643
|
+
.map((h) => String(h.matcher));
|
|
644
|
+
const json = { ok: true, removed };
|
|
645
|
+
if (stillGating.length > 0) {
|
|
646
|
+
json.stillGated = stillGating;
|
|
647
|
+
json.note = `Removed this tool's own ${event} entry, but ${stillGating.length} broader `
|
|
648
|
+
+ `hook(s) still gate "${gateName}" (matcher(s): ${stillGating.join(', ')}). `
|
|
649
|
+
+ 'Disable or edit those on the Hooks tab to ungate this tool.';
|
|
650
|
+
}
|
|
651
|
+
return { status: 200, json };
|
|
447
652
|
}
|
|
448
653
|
|
|
449
654
|
// GET /api/upstreams -> { upstreams:[...], expose:[...] } (READ-ONLY for v1).
|
|
@@ -615,7 +820,9 @@ function createUiServer(opts = {}) {
|
|
|
615
820
|
}
|
|
616
821
|
|
|
617
822
|
// POST /api/logs/config { enabled?, path? } -> logger.setConfig (atomic merge). Validates field
|
|
618
|
-
// types so a bad shape is a clean 400 rather than reaching the writer
|
|
823
|
+
// types so a bad shape is a clean 400 rather than reaching the writer. setConfig's own refusal
|
|
824
|
+
// of an out-of-home path (TF_LOG_PATH_REFUSED) is client input too -> 400; only a genuine
|
|
825
|
+
// write failure (disk, perms) -> 500.
|
|
619
826
|
function apiSetLogsConfig(body) {
|
|
620
827
|
const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
621
828
|
const patch = {};
|
|
@@ -635,7 +842,8 @@ function createUiServer(opts = {}) {
|
|
|
635
842
|
const config = logger.setConfig(patch);
|
|
636
843
|
return { status: 200, json: { ok: true, config } };
|
|
637
844
|
} catch (err) {
|
|
638
|
-
|
|
845
|
+
const status = err && err.code === 'TF_LOG_PATH_REFUSED' ? 400 : 500;
|
|
846
|
+
return { status, json: { ok: false, error: (err && err.message) || 'log config write failed' } };
|
|
639
847
|
}
|
|
640
848
|
}
|
|
641
849
|
|
|
@@ -708,7 +916,26 @@ function createUiServer(opts = {}) {
|
|
|
708
916
|
|
|
709
917
|
// POST /api/oauth/install -> install the single optional dependency (jose@PIN) on demand. Async +
|
|
710
918
|
// potentially slow (it shells out to npm); installJose() owns a hard timeout and never throws.
|
|
919
|
+
//
|
|
920
|
+
// NOT on the POST mutation chain (0.7.0): this writes node_modules/, not the config files that
|
|
921
|
+
// chain exists to serialise, so queueing it there put a multi-minute npm run in front of every
|
|
922
|
+
// other POST - one click and the whole panel stops answering. It still must not overlap ITSELF,
|
|
923
|
+
// so it keeps a single-flight latch of its own: concurrent callers COALESCE onto the running
|
|
924
|
+
// install and get its result rather than starting a second npm.
|
|
925
|
+
let installInFlight = null;
|
|
711
926
|
async function apiOauthInstall() {
|
|
927
|
+
if (installInFlight) return installInFlight;
|
|
928
|
+
installInFlight = (async () => {
|
|
929
|
+
try {
|
|
930
|
+
return await runOauthInstall();
|
|
931
|
+
} finally {
|
|
932
|
+
installInFlight = null;
|
|
933
|
+
}
|
|
934
|
+
})();
|
|
935
|
+
return installInFlight;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async function runOauthInstall() {
|
|
712
939
|
let res;
|
|
713
940
|
try {
|
|
714
941
|
res = await installJose();
|
|
@@ -741,7 +968,24 @@ function createUiServer(opts = {}) {
|
|
|
741
968
|
delete clean.scriptText; // scriptText is a UI affordance, not a register field
|
|
742
969
|
// Author the script body before registering, so a script invoke points at a real file.
|
|
743
970
|
if (typeof scriptText === 'string' && clean.invoke && clean.invoke.type === 'script') {
|
|
971
|
+
// Snapshot the slot the write lands on (same basename rule as writeScript), so a
|
|
972
|
+
// refused register entry can put it back exactly - absent stays absent, an
|
|
973
|
+
// overwritten body returns. Without this the 400 reply left the authored script
|
|
974
|
+
// behind as an orphan no entry references.
|
|
975
|
+
const slot = typeof clean.invoke.path === 'string' && clean.invoke.path.length > 0
|
|
976
|
+
? path.resolve(SCRIPTS_ROOT, path.basename(clean.invoke.path)) : null;
|
|
977
|
+
const priorBody = slot && fs.existsSync(slot) ? fs.readFileSync(slot) : null;
|
|
744
978
|
registry.writeScript(clean.invoke.path, scriptText);
|
|
979
|
+
try {
|
|
980
|
+
const added = registry.add(clean);
|
|
981
|
+
return { status: 200, json: { ok: true, entry: added } };
|
|
982
|
+
} catch (err) {
|
|
983
|
+
try {
|
|
984
|
+
if (priorBody != null) fs.writeFileSync(slot, priorBody);
|
|
985
|
+
else if (slot) fs.unlinkSync(slot);
|
|
986
|
+
} catch (_e) { /* best-effort - the 400 below still reports the add failure */ }
|
|
987
|
+
throw err;
|
|
988
|
+
}
|
|
745
989
|
}
|
|
746
990
|
const added = registry.add(clean);
|
|
747
991
|
return { status: 200, json: { ok: true, entry: added } };
|
|
@@ -898,7 +1142,15 @@ function createUiServer(opts = {}) {
|
|
|
898
1142
|
}
|
|
899
1143
|
loader.addEntry(spec); // validates id/event/command; throws on duplicate id
|
|
900
1144
|
if (typeof scriptText === 'string' && scriptText.length > 0) {
|
|
901
|
-
|
|
1145
|
+
try {
|
|
1146
|
+
loader.writeScript(spec.id, scriptText); // path-guarded to hooks/scripts
|
|
1147
|
+
} catch (err) {
|
|
1148
|
+
// The entry persisted before the script slot was refused. A reply that
|
|
1149
|
+
// reports failure must not keep half the add - without the unwind, the
|
|
1150
|
+
// natural retry died on its own first attempt as a duplicate id.
|
|
1151
|
+
try { loader.removeEntry(spec.id); } catch (_e) { /* best-effort */ }
|
|
1152
|
+
throw err;
|
|
1153
|
+
}
|
|
902
1154
|
}
|
|
903
1155
|
return { status: 200, json: { ok: true, id: spec.id } };
|
|
904
1156
|
} catch (err) {
|
|
@@ -1026,7 +1278,7 @@ function createUiServer(opts = {}) {
|
|
|
1026
1278
|
// hard-refused exactly the upstreams the wrap security notice tells operators to curate
|
|
1027
1279
|
// HERE - per-tool enabled:false via this discover.
|
|
1028
1280
|
const agg = new Aggregator({
|
|
1029
|
-
store,
|
|
1281
|
+
store, gatewayRoot: root,
|
|
1030
1282
|
wrapTargetProvider: () => {
|
|
1031
1283
|
try { return getPassthrough(loadToolState(TOOL_STATE_PATH)); } catch (_e) { return null; }
|
|
1032
1284
|
},
|
|
@@ -1060,22 +1312,38 @@ function createUiServer(opts = {}) {
|
|
|
1060
1312
|
// The POST routing table - path -> handler. Each handler takes the parsed body and returns
|
|
1061
1313
|
// { status, json } (or a Promise of it - the dispatcher awaits); none throws (every store call is
|
|
1062
1314
|
// wrapped). Built once per server instance.
|
|
1315
|
+
//
|
|
1316
|
+
// Every POST handler read-modify-writes a config file. atomicWriteJson makes each individual
|
|
1317
|
+
// write atomic, but two CONCURRENT posts (two tabs, a double-click) both read the pre-state and
|
|
1318
|
+
// the second write silently discards the first - and on /api/tools/hook a lost write is a lost
|
|
1319
|
+
// GATE. Serialise all mutators behind one promise chain: loopback + single operator means the
|
|
1320
|
+
// queue is depth 0 or 1 in practice, so the latency cost is nil.
|
|
1321
|
+
let postChain = Promise.resolve();
|
|
1322
|
+
const serialise = (fn) => (body) => {
|
|
1323
|
+
const run = postChain.then(() => fn(body));
|
|
1324
|
+
// The chain must survive a rejected handler (the dispatcher maps rejections to 500 itself).
|
|
1325
|
+
postChain = run.catch(() => {});
|
|
1326
|
+
return run;
|
|
1327
|
+
};
|
|
1063
1328
|
const POST_HANDLERS = {
|
|
1064
|
-
'/api/tools/state': apiSetState,
|
|
1065
|
-
'/api/tools/hook': apiSetHook,
|
|
1066
|
-
'/api/tools/add': apiToolAdd,
|
|
1067
|
-
'/api/tools/remove': apiToolRemove,
|
|
1068
|
-
'/api/tools/mode': apiToolMode,
|
|
1069
|
-
'/api/tools/update': apiToolUpdate,
|
|
1070
|
-
'/api/hooks/add': apiHookAdd,
|
|
1071
|
-
'/api/hooks/state': apiHookState,
|
|
1072
|
-
'/api/mcp/add': apiMcpAdd,
|
|
1073
|
-
'/api/mcp/state': apiMcpState,
|
|
1329
|
+
'/api/tools/state': serialise(apiSetState),
|
|
1330
|
+
'/api/tools/hook': serialise(apiSetHook),
|
|
1331
|
+
'/api/tools/add': serialise(apiToolAdd),
|
|
1332
|
+
'/api/tools/remove': serialise(apiToolRemove),
|
|
1333
|
+
'/api/tools/mode': serialise(apiToolMode),
|
|
1334
|
+
'/api/tools/update': serialise(apiToolUpdate),
|
|
1335
|
+
'/api/hooks/add': serialise(apiHookAdd),
|
|
1336
|
+
'/api/hooks/state': serialise(apiHookState),
|
|
1337
|
+
'/api/mcp/add': serialise(apiMcpAdd),
|
|
1338
|
+
'/api/mcp/state': serialise(apiMcpState),
|
|
1339
|
+
// discover is a read-only probe - no config write, no need to queue it behind mutations.
|
|
1074
1340
|
'/api/mcp/discover': apiMcpDiscover,
|
|
1075
|
-
'/api/wrap': apiWrap,
|
|
1076
|
-
'/api/identity': apiSetIdentity,
|
|
1077
|
-
'/api/logs/config': apiSetLogsConfig,
|
|
1078
|
-
'/api/auth/config': apiSetAuthConfig,
|
|
1341
|
+
'/api/wrap': serialise(apiWrap),
|
|
1342
|
+
'/api/identity': serialise(apiSetIdentity),
|
|
1343
|
+
'/api/logs/config': serialise(apiSetLogsConfig),
|
|
1344
|
+
'/api/auth/config': serialise(apiSetAuthConfig),
|
|
1345
|
+
// The optional-dependency install writes node_modules/, not the config files the chain
|
|
1346
|
+
// protects, and takes minutes - it carries its own single-flight latch instead (see above).
|
|
1079
1347
|
'/api/oauth/install': apiOauthInstall,
|
|
1080
1348
|
};
|
|
1081
1349
|
|
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
* failure: { ok:false, error }
|
|
23
23
|
*
|
|
24
24
|
* Mapping (verified against src/core/hook-loader.js):
|
|
25
|
-
* - enable -> loader.setEnabled(id, true) (persists the state overlay + manifest
|
|
26
|
-
*
|
|
25
|
+
* - enable -> loader.setEnabled(id, true) (persists the state overlay + manifest;
|
|
26
|
+
* returns false on a miss - no manifest row, no detected script - and
|
|
27
|
+
* writes NOTHING, so a miss is reported as { ok:false, error })
|
|
28
|
+
* - disable -> loader.setEnabled(id, false) (same miss contract)
|
|
27
29
|
* - remove -> loader.removeEntry(id) (returns false if the id was absent;
|
|
28
30
|
* that is information, not a fault, so ok stays true)
|
|
29
31
|
*
|
|
@@ -89,7 +91,13 @@ function run(args) {
|
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
const desired = action === 'enable';
|
|
92
|
-
|
|
94
|
+
// setEnabled reports whether the toggle matched anything (a manifest row or a
|
|
95
|
+
// detected script). A miss writes nothing, so claiming ok:true here would report
|
|
96
|
+
// a gate toggled when nothing changed.
|
|
97
|
+
const applied = loader.setEnabled(id, desired);
|
|
98
|
+
if (!applied) {
|
|
99
|
+
return { ok: false, error: `no hook with id "${id}" - nothing was changed` };
|
|
100
|
+
}
|
|
93
101
|
return { ok: true, id, action, enabled: desired };
|
|
94
102
|
}
|
|
95
103
|
|