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.
Files changed (42) hide show
  1. package/README.md +11 -7
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +97 -18
  4. package/docs/KNOWN_BUGS.md +71 -90
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +24 -10
  7. package/hooks/hooks.manifest.json +1 -1
  8. package/hooks/scripts/example-deny-dangerous.js +12 -4
  9. package/mcp/servers/mock-upstream/server.js +36 -6
  10. package/package.json +67 -65
  11. package/src/auth/config.js +2 -2
  12. package/src/auth/resource-server.js +12 -3
  13. package/src/core/child-reaper.js +61 -0
  14. package/src/core/events.js +7 -7
  15. package/src/core/hook-engine.js +85 -31
  16. package/src/core/hook-loader.js +46 -13
  17. package/src/core/hook-runner.js +258 -38
  18. package/src/core/logger.js +138 -12
  19. package/src/core/matcher.js +59 -10
  20. package/src/core/rename-migration.js +163 -0
  21. package/src/core/requires.js +1 -1
  22. package/src/extend/add-hook.md +2 -2
  23. package/src/extend/add-mcp.md +3 -6
  24. package/src/extend/configure.md +2 -1
  25. package/src/extend/create-tool.md +1 -1
  26. package/src/extend/howto.js +3 -3
  27. package/src/extend/package.md +11 -8
  28. package/src/mcp/aggregator.js +480 -52
  29. package/src/mcp/expose-store.js +130 -13
  30. package/src/mcp/gated-run.js +26 -17
  31. package/src/mcp/http-transport.js +274 -22
  32. package/src/mcp/mcp-client.js +194 -31
  33. package/src/mcp/modern.js +44 -10
  34. package/src/mcp/server.js +513 -89
  35. package/src/tools/registry.js +172 -24
  36. package/src/ui/public/app.js +17 -4
  37. package/src/ui/server.js +297 -29
  38. package/tools/scripts/tf-hook-set.js +11 -3
  39. package/tools/tools.register.json +227 -227
  40. package/src/core/tool-registry.js +0 -180
  41. package/src/packages/packager.js +0 -715
  42. 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 = readManifest().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
- // Append a literal-full-match hook iff one is not already enabled for (event, gateName).
416
- if (!hookFires(hooks, event, gateName)) {
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: escapeRegex(gateName),
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: 'node "${HOOKS_DIR}/scripts/' + scriptName + '"',
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 every entry for this event whose matcher fires for this tool's gate name.
441
- const next = hooks.filter((h) => !(h && h.event === event && matches(h.matcher, gateName)));
442
- if (next.length !== hooks.length) {
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
- return { status: 200, json: { ok: true } };
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; a write failure -> 500.
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
- return { status: 500, json: { ok: false, error: (err && err.message) || 'log config write failed' } };
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
- loader.writeScript(spec.id, scriptText); // path-guarded to hooks/scripts
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, v3Root: root,
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
- * - disable -> loader.setEnabled(id, false)
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
- loader.setEnabled(id, desired);
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