toolfunnel 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +228 -235
- package/SECURITY.md +84 -84
- package/bin/toolfunnel.js +451 -451
- package/docs/KNOWN_BUGS.md +80 -80
- package/docs/design.md +84 -84
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -39
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +380 -380
- package/package.json +67 -67
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +353 -353
- package/src/core/child-reaper.js +61 -61
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +329 -329
- package/src/core/hook-loader.js +824 -824
- package/src/core/hook-runner.js +566 -566
- package/src/core/logger.js +297 -297
- package/src/core/matcher.js +100 -100
- package/src/core/metrics.js +75 -75
- package/src/core/rename-migration.js +163 -163
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +152 -152
- package/src/extend/add-mcp.md +119 -119
- package/src/extend/configure.md +88 -88
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -190
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +1719 -1674
- package/src/mcp/expose-store.js +577 -577
- package/src/mcp/gated-run.js +210 -210
- package/src/mcp/http-transport.js +1563 -1563
- package/src/mcp/mcp-client.js +1070 -1070
- package/src/mcp/modern.js +406 -406
- package/src/mcp/protocol.js +468 -468
- package/src/mcp/server.js +3112 -3112
- package/src/tools/registry.js +661 -661
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1187 -1187
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1616 -1616
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -120
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
package/src/ui/server.js
CHANGED
|
@@ -1,1616 +1,1616 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* server.js - the OPTIONAL config web UI for the gateway (loopback-only).
|
|
5
|
-
*
|
|
6
|
-
* A zero-dependency node:http server that lets a human VIEW / SEARCH / CONFIGURE
|
|
7
|
-
* the gateway's tools, upstream MCPs, and hooks without hand-editing JSON. It does
|
|
8
|
-
* NOT reinvent any state: every read and write goes THROUGH the gateway's proven stores -
|
|
9
|
-
* - the tool register (src/tools/registry.js :: loadRegistry -> add/update/remove/writeScript)
|
|
10
|
-
* - the enabled overlay (src/tools/tool-state.js :: loadToolState / setToolEnabled / clearToolState)
|
|
11
|
-
* - the MCP expose store (src/mcp/expose-store.js :: loadExposeStore -> addUpstream/addExpose/...)
|
|
12
|
-
* - the hook loader (src/core/hook-loader.js :: loadManifest -> addEntry/setEnabled/removeEntry)
|
|
13
|
-
* - the hook matcher (src/core/matcher.js :: matches)
|
|
14
|
-
* - the atomic writer (src/tools/registry.js :: atomicWriteJson) [tool Pre/Post gate writes]
|
|
15
|
-
* so a UI edit is byte-identical to a CLI edit and is visible to the running MCP
|
|
16
|
-
* server with no restart (the register/state/manifest are all re-read per call).
|
|
17
|
-
*
|
|
18
|
-
* The UI runs in-process for the trusted human operator, so it uses the stores
|
|
19
|
-
* DIRECTLY (no MCP round-trip). Every write is atomic (temp + rename) inside the store.
|
|
20
|
-
*
|
|
21
|
-
* SAFETY CONTRACT (mirrors src/mcp/http-transport.js):
|
|
22
|
-
* - start() HARD-REFUSES a non-loopback bind host. The UI has no auth path at all, and it can
|
|
23
|
-
* spawn processes (mcp add/discover) and write scripts (tools/hooks) - off-loopback it would be
|
|
24
|
-
* an unauthenticated remote console. Loopback-only by design; no flag overrides it.
|
|
25
|
-
* - Binds 127.0.0.1 by default; a non-loopback Host header is rejected (DNS-rebind guard).
|
|
26
|
-
* - The request listener NEVER throws - a bad request becomes a clean JSON error.
|
|
27
|
-
* - start() rejects cleanly on EADDRINUSE (caller decides); stop() is idempotent.
|
|
28
|
-
* - Every store read is FRESH per request, so edits made elsewhere show immediately.
|
|
29
|
-
*
|
|
30
|
-
* CommonJS only. Node BUILT-INS only (node:http, node:fs, node:path) - no new npm dep,
|
|
31
|
-
* no CDN, no framework. The static assets under src/ui/public are vanilla HTML/CSS/JS.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
const http = require('node:http');
|
|
35
|
-
const fs = require('node:fs');
|
|
36
|
-
const path = require('node:path');
|
|
37
|
-
|
|
38
|
-
const { loadRegistry, atomicWriteJson, resolveMode } = require('../tools/registry');
|
|
39
|
-
const { loadToolState, isToolEnabled, isToolHidden, isToolHot, setToolEnabled, setToolHidden, setToolHot, clearToolState, getPassthrough, setPassthrough } = require('../tools/tool-state');
|
|
40
|
-
const { loadExposeStore } = require('../mcp/expose-store');
|
|
41
|
-
const { Aggregator } = require('../mcp/aggregator');
|
|
42
|
-
const { loadManifest, normalizeState } = require('../core/hook-loader');
|
|
43
|
-
const { matches } = require('../core/matcher');
|
|
44
|
-
const { META_TOOLS } = require('../mcp/protocol');
|
|
45
|
-
const logger = require('../core/logger');
|
|
46
|
-
const { loadServerConfig } = require('../core/server-config');
|
|
47
|
-
const authConfig = require('../auth/config');
|
|
48
|
-
const { isJoseInstalled, JOSE_PIN } = require('../auth/resource-server');
|
|
49
|
-
const { installJose } = require('../auth/install');
|
|
50
|
-
|
|
51
|
-
const DEFAULT_HOST = '127.0.0.1';
|
|
52
|
-
const DEFAULT_PORT = 9777;
|
|
53
|
-
// Config POST bodies are small, but an "add" may carry a scriptText file body; 256 KiB is
|
|
54
|
-
// generous headroom for a tool/hook script while still capping an abusive request.
|
|
55
|
-
const MAX_BODY_BYTES = 256 * 1024;
|
|
56
|
-
const VALID_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
|
|
57
|
-
|
|
58
|
-
// Static asset content types (vanilla, offline - no external fonts/CDN).
|
|
59
|
-
const CONTENT_TYPES = Object.freeze({
|
|
60
|
-
'.html': 'text/html; charset=utf-8',
|
|
61
|
-
'.css': 'text/css; charset=utf-8',
|
|
62
|
-
'.js': 'text/javascript; charset=utf-8',
|
|
63
|
-
'.json': 'application/json; charset=utf-8',
|
|
64
|
-
'.jpg': 'image/jpeg',
|
|
65
|
-
'.jpeg': 'image/jpeg',
|
|
66
|
-
'.png': 'image/png',
|
|
67
|
-
'.svg': 'image/svg+xml',
|
|
68
|
-
'.ico': 'image/x-icon',
|
|
69
|
-
'.webp': 'image/webp',
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Is the Host header loopback? We bind 127.0.0.1 only, but a forwarded/rebound
|
|
74
|
-
* request could still arrive with a non-loopback Host. Defence-in-depth: reject
|
|
75
|
-
* anything whose host part is not a recognised loopback name (mirrors
|
|
76
|
-
* http-transport.js::isLoopbackHost). A MISSING Host header is allowed - the bind
|
|
77
|
-
* address is already the hard boundary.
|
|
78
|
-
* @param {string|undefined} hostHeader
|
|
79
|
-
* @returns {boolean}
|
|
80
|
-
*/
|
|
81
|
-
function isLoopbackHost(hostHeader) {
|
|
82
|
-
if (hostHeader == null || hostHeader === '') return true;
|
|
83
|
-
let host = String(hostHeader).trim();
|
|
84
|
-
if (host[0] === '[') {
|
|
85
|
-
const end = host.indexOf(']');
|
|
86
|
-
host = end === -1 ? host.slice(1) : host.slice(1, end);
|
|
87
|
-
} else {
|
|
88
|
-
// Strip an optional :port ONLY when unambiguous - exactly one colon means "host:port". A string
|
|
89
|
-
// with MULTIPLE colons is a bare IPv6 literal (e.g. "::1"); compare it whole rather than slicing
|
|
90
|
-
// it to "" at the first colon (which would mis-classify IPv6 loopback as non-loopback).
|
|
91
|
-
const first = host.indexOf(':');
|
|
92
|
-
if (first !== -1 && first === host.lastIndexOf(':')) host = host.slice(0, first);
|
|
93
|
-
}
|
|
94
|
-
host = host.toLowerCase();
|
|
95
|
-
return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1';
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Is the BIND address loopback? STRICTER than the Host-header check above: a missing Host header
|
|
100
|
-
* is legitimately loopback (the bind address is the boundary), but a missing/empty BIND address is
|
|
101
|
-
* not a loopback claim - it must name a loopback host explicitly to pass.
|
|
102
|
-
* @param {string|undefined} bindHost
|
|
103
|
-
* @returns {boolean}
|
|
104
|
-
*/
|
|
105
|
-
function isLoopbackBindHost(bindHost) {
|
|
106
|
-
return typeof bindHost === 'string' && bindHost.trim() !== '' && isLoopbackHost(bindHost);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Regex-escape a literal so a matcher built from it FULL-matches the string verbatim. */
|
|
110
|
-
function escapeRegex(str) {
|
|
111
|
-
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* The SURFACED name a discovered upstream tool is exposed under - an ENABLED expose `as` for
|
|
116
|
-
* (upstream, tool) if one exists, else the namespaced default `<upstream>_<tool>`. Mirrors
|
|
117
|
-
* Aggregator._surfacedName so the UI's per-tool curation keys match what the running gateway reads.
|
|
118
|
-
* @param {object} store the ExposeStore (for exposedName)
|
|
119
|
-
* @param {Array} enabledExposed store.listExposed({ upstream, enabledOnly:true })
|
|
120
|
-
* @param {string} upstreamId
|
|
121
|
-
* @param {string} toolName
|
|
122
|
-
* @returns {string}
|
|
123
|
-
*/
|
|
124
|
-
function surfacedNameFor(store, enabledExposed, upstreamId, toolName) {
|
|
125
|
-
for (const e of enabledExposed || []) {
|
|
126
|
-
if (e && e.tool === toolName) return store.exposedName(e);
|
|
127
|
-
}
|
|
128
|
-
return `${upstreamId}_${toolName}`;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Race a promise against a timeout so a wedged upstream never hangs a UI request. Rejects with
|
|
132
|
-
* `msg` on timeout; the timer is unref'd so it never keeps the process alive. */
|
|
133
|
-
function withTimeout(promise, ms, msg) {
|
|
134
|
-
return new Promise((resolve, reject) => {
|
|
135
|
-
const timer = setTimeout(() => reject(new Error(msg || 'timed out')), ms);
|
|
136
|
-
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
137
|
-
Promise.resolve(promise).then(
|
|
138
|
-
(v) => { clearTimeout(timer); resolve(v); },
|
|
139
|
-
(e) => { clearTimeout(timer); reject(e); }
|
|
140
|
-
);
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Diagnostics -> stderr (never the response). NEVER throws. */
|
|
145
|
-
function logErr(...parts) {
|
|
146
|
-
try {
|
|
147
|
-
process.stderr.write('[toolfunnel-ui] ' + parts.join(' ') + '\n');
|
|
148
|
-
} catch (_e) {
|
|
149
|
-
/* never let logging throw */
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ── Config-change audit (UI -> activity log) ─────────────────────────────────────────────────────
|
|
154
|
-
// A management console mutates the gateway's security posture (which tools are visible/hot, which
|
|
155
|
-
// MCPs are attached, which hooks gate, whether OAuth is on). Auditing those changes is arguably more
|
|
156
|
-
// important than logging individual tool runs. This maps each config-MUTATING POST path to an audit
|
|
157
|
-
// record (body -> fields). Read-only / test endpoints (e.g. /api/mcp/discover) are deliberately ABSENT
|
|
158
|
-
// so they never log as a "change". Logging self-gates (logger.log is a no-op unless logging is on),
|
|
159
|
-
// so this is silent until the operator enables the activity log.
|
|
160
|
-
const CONFIG_EVENTS = {
|
|
161
|
-
'/api/tools/state': (b) => ({ event: 'tool_state', id: b.id, enabled: b.enabled, hidden: b.hidden, hot: b.hot }),
|
|
162
|
-
'/api/tools/hook': (b) => ({ event: 'tool_hook', id: b.id, hook: b.event, on: !!b.on }),
|
|
163
|
-
'/api/tools/add': (b) => ({ event: 'tool_add', id: b.entry && b.entry.id }),
|
|
164
|
-
'/api/tools/remove': (b) => ({ event: 'tool_remove', id: b.id }),
|
|
165
|
-
'/api/tools/mode': (b) => ({ event: 'tool_mode', id: b.id, mode: b.mode }),
|
|
166
|
-
'/api/tools/update': (b) => ({ event: 'tool_update', id: b.id }),
|
|
167
|
-
'/api/hooks/add': (b) => ({ event: 'hook_add', id: b.entry && b.entry.id }),
|
|
168
|
-
'/api/hooks/state': (b) => ({ event: 'hook_state', id: b.id, action: b.action }),
|
|
169
|
-
'/api/mcp/add': (b) => ({ event: 'mcp_add', id: b.upstream && b.upstream.id }),
|
|
170
|
-
'/api/mcp/state': (b) => ({ event: 'mcp_state', id: b.id, action: b.action }),
|
|
171
|
-
'/api/wrap': (b) => ({ event: 'wrap', upstream: (b && b.upstream) || null }),
|
|
172
|
-
'/api/identity': () => ({ event: 'identity_config' }),
|
|
173
|
-
'/api/auth/config': (b) => ({ event: 'auth_config', enabled: b.enabled, issuer: b.issuer }),
|
|
174
|
-
'/api/logs/config': (b) => ({ event: 'log_config', enabled: b.enabled }),
|
|
175
|
-
'/api/oauth/install': () => ({ event: 'oauth_install' }),
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Write a config-change audit record (type:'config', via:'ui'). Drops undefined fields so a partial
|
|
180
|
-
* change (e.g. only `hot` toggled) records only what actually changed. Self-gating via logger.log
|
|
181
|
-
* (no-op unless logging is enabled). NEVER throws - an audit failure must not break a config write.
|
|
182
|
-
* @param {object} fields { event, ...changed }
|
|
183
|
-
*/
|
|
184
|
-
function logConfigChange(fields) {
|
|
185
|
-
try {
|
|
186
|
-
const rec = { type: 'config', via: 'ui' };
|
|
187
|
-
const f = fields || {};
|
|
188
|
-
for (const k of Object.keys(f)) {
|
|
189
|
-
if (f[k] !== undefined) rec[k] = f[k];
|
|
190
|
-
}
|
|
191
|
-
logger.log(rec);
|
|
192
|
-
} catch (_e) {
|
|
193
|
-
/* never let audit logging break the caller */
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* createUiServer - construct (but do NOT start) the config web UI server.
|
|
199
|
-
*
|
|
200
|
-
* @param {object} [opts]
|
|
201
|
-
* @param {string} [opts.host] bind address (default 127.0.0.1 - loopback only).
|
|
202
|
-
* @param {number} [opts.port] bind port (default 9777; pass 0 for an OS-assigned port).
|
|
203
|
-
* @param {string} [opts.root] config root holding tools/ mcp/ hooks/ (default: the resolved
|
|
204
|
-
* config home - TOOLFUNNEL_HOME / --config-dir / the package root).
|
|
205
|
-
* @returns {{ start: () => Promise<{url:string, port:number}>, stop: () => Promise<void>, get url():(string|null) }}
|
|
206
|
-
*/
|
|
207
|
-
function createUiServer(opts = {}) {
|
|
208
|
-
const o = opts || {};
|
|
209
|
-
const host = typeof o.host === 'string' && o.host.length > 0 ? o.host : DEFAULT_HOST;
|
|
210
|
-
// A port of 0 is VALID (OS-assigned) so distinguish "not provided" from 0.
|
|
211
|
-
const requestedPort = Number.isInteger(o.port) && o.port >= 0 ? o.port : DEFAULT_PORT;
|
|
212
|
-
// The config root: tools.register.json etc. live under <root>/tools, <root>/mcp, <root>/hooks.
|
|
213
|
-
// Default = the resolved config home (see src/core/config-home.js), so a --config-dir /
|
|
214
|
-
// TOOLFUNNEL_HOME gateway and its UI edit the SAME stores.
|
|
215
|
-
const { resolveConfigHome } = require('../core/config-home');
|
|
216
|
-
const root = typeof o.root === 'string' && o.root.length > 0 ? o.root : resolveConfigHome();
|
|
217
|
-
|
|
218
|
-
// ── Path anchors (everything resolved from the repo root) ────────────────────────────────────
|
|
219
|
-
const REGISTER_PATH = path.join(root, 'tools', 'tools.register.json');
|
|
220
|
-
const TOOL_STATE_PATH = path.join(root, 'tools', 'tools.state.json');
|
|
221
|
-
const EXPOSE_PATH = path.join(root, 'mcp', 'expose.json');
|
|
222
|
-
const MANIFEST_PATH = path.join(root, 'hooks', 'hooks.manifest.json');
|
|
223
|
-
const HOOK_STATE_PATH = path.join(root, 'hooks', 'hooks.state.json');
|
|
224
|
-
const SCRIPTS_ROOT = path.join(root, 'tools', 'scripts');
|
|
225
|
-
// Where a Pre/Post hook script for a tool must be authored. The manifest command carries the
|
|
226
|
-
// PORTABLE ${HOOKS_DIR} token (expanded by the hook-loader at load); this is its real on-disk dir.
|
|
227
|
-
const HOOK_SCRIPTS_DIR = path.join(root, 'hooks', 'scripts');
|
|
228
|
-
const TOOLFUNNEL_JSON = path.join(root, 'toolfunnel.json');
|
|
229
|
-
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
230
|
-
|
|
231
|
-
// ── Mutable runtime state ─────────────────────────────────────────────────────────────────────
|
|
232
|
-
/** @type {import('node:http').Server|null} */
|
|
233
|
-
let httpServer = null;
|
|
234
|
-
/** @type {number|null} the actual bound port (resolved after listen - matters for port 0). */
|
|
235
|
-
let boundPort = null;
|
|
236
|
-
let started = false;
|
|
237
|
-
|
|
238
|
-
function currentUrl() {
|
|
239
|
-
return boundPort == null ? null : `http://${host}:${boundPort}`;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// ── Store-backed config reads (FRESH per call so external edits show immediately) ──────────────
|
|
243
|
-
|
|
244
|
-
/** Read the hook manifest defensively -> { version, hooks:[] }. Never throws. */
|
|
245
|
-
function readManifest() {
|
|
246
|
-
try {
|
|
247
|
-
const d = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
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.
|
|
252
|
-
return {
|
|
253
|
-
...d,
|
|
254
|
-
version: typeof d.version === 'number' ? d.version : 1,
|
|
255
|
-
hooks: Array.isArray(d.hooks) ? d.hooks : [],
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
} catch (_e) {
|
|
259
|
-
/* missing/malformed -> empty manifest */
|
|
260
|
-
}
|
|
261
|
-
return { version: 1, hooks: [] };
|
|
262
|
-
}
|
|
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
|
-
|
|
296
|
-
/** Does any ENABLED manifest hook for `event` fire for `gateName`? (matcher.matches semantics) */
|
|
297
|
-
function hookFires(hooks, event, gateName) {
|
|
298
|
-
for (const h of hooks) {
|
|
299
|
-
if (!h || h.event !== event || h.enabled !== true) continue;
|
|
300
|
-
if (matches(h.matcher, gateName)) return true;
|
|
301
|
-
}
|
|
302
|
-
return false;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** The gate name the hook matcher is tested against: register `name`, falling back to `id`. */
|
|
306
|
-
function gateNameFor(entry) {
|
|
307
|
-
return (entry && entry.name) || (entry && entry.id) || '';
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// GET /api/tools -> [{ id,name,summary,category, mode, enabled, hidden, hot, pre, post }]
|
|
311
|
-
function apiTools() {
|
|
312
|
-
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
313
|
-
const state = loadToolState(TOOL_STATE_PATH);
|
|
314
|
-
const hooks = effectiveHooks();
|
|
315
|
-
return registry.list().map((b) => {
|
|
316
|
-
const gateName = b.name || b.id;
|
|
317
|
-
return {
|
|
318
|
-
id: b.id,
|
|
319
|
-
name: b.name,
|
|
320
|
-
summary: b.summary || '',
|
|
321
|
-
category: b.category || '',
|
|
322
|
-
// The RESOLVED execution mode ("gateway" runs the invoke here; "reference"
|
|
323
|
-
// hands the instructions back so the connected AI runs it). b.id is from the
|
|
324
|
-
// register so registry.mode never throws here.
|
|
325
|
-
mode: registry.mode(b.id),
|
|
326
|
-
// The visibility MATRIX axes (all keyed by the tool id):
|
|
327
|
-
// enabled - LEAN-VISIBLE in toolfunnel_list_tools + runnable (default ON).
|
|
328
|
-
// hidden - manager-list declutter only (default OFF).
|
|
329
|
-
// hot - promoted to the TOP-LEVEL every-turn surface (default OFF; opt-in).
|
|
330
|
-
enabled: isToolEnabled(state, b.id),
|
|
331
|
-
hidden: isToolHidden(state, b.id),
|
|
332
|
-
hot: isToolHot(state, b.id, false),
|
|
333
|
-
pre: hookFires(hooks, 'PreToolUse', gateName),
|
|
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"'),
|
|
347
|
-
};
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// POST /api/tools/state {id, enabled?, hidden?, hot?} -> persist whichever axes are present. Each
|
|
352
|
-
// is an independent merge-write (preserves the others). Backward-compatible: {id, enabled} still
|
|
353
|
-
// just flips enabled. `id` may be a local tool id, an upstream surfaced name, or a meta-tool name
|
|
354
|
-
// (the `hot` axis is keyed by the surfaced name the matrix assembler reads).
|
|
355
|
-
function apiSetState(body) {
|
|
356
|
-
const id = body && body.id;
|
|
357
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
358
|
-
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
359
|
-
}
|
|
360
|
-
let touched = false;
|
|
361
|
-
// Wrap the atomic writes (they can throw on EACCES/EPERM/ENOSPC) so the handler honours the
|
|
362
|
-
// POST contract "never throws" and returns a clean {ok:false} with a useful message, like the
|
|
363
|
-
// other write handlers - rather than escaping to the generic outer 500.
|
|
364
|
-
try {
|
|
365
|
-
if (Object.prototype.hasOwnProperty.call(body, 'enabled')) { setToolEnabled(TOOL_STATE_PATH, id, !!body.enabled); touched = true; }
|
|
366
|
-
if (Object.prototype.hasOwnProperty.call(body, 'hidden')) { setToolHidden(TOOL_STATE_PATH, id, !!body.hidden); touched = true; }
|
|
367
|
-
if (Object.prototype.hasOwnProperty.call(body, 'hot')) { setToolHot(TOOL_STATE_PATH, id, !!body.hot); touched = true; }
|
|
368
|
-
} catch (err) {
|
|
369
|
-
return { status: 500, json: { ok: false, error: (err && err.message) || 'tool state write failed' } };
|
|
370
|
-
}
|
|
371
|
-
if (!touched) {
|
|
372
|
-
return { status: 400, json: { ok: false, error: 'provide at least one of enabled, hidden, hot' } };
|
|
373
|
-
}
|
|
374
|
-
return { status: 200, json: { ok: true } };
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// GET /api/surface -> the TOP-LEVEL (every-turn) surface summary + footgun warnings.
|
|
378
|
-
// meta - the 4 meta-tools with their hot state (default ON; hot:false hides one).
|
|
379
|
-
// promotedLocal - register tools promoted hot AND enabled.
|
|
380
|
-
// promotedOther - other hot+enabled state keys (upstream surfaced names) - a count proxy (the
|
|
381
|
-
// UI server can't enumerate live upstream tools without a discover, so this may
|
|
382
|
-
// over-count typo'd / disabled-upstream keys; it errs toward over-warning).
|
|
383
|
-
// curatedDirect - ENABLED expose[] entries: these are on the top-level surface every turn too
|
|
384
|
-
// (handleToolsList step 3 promotes every enabled expose entry, with NO hot flag),
|
|
385
|
-
// so the bloat warning MUST count them or it reports a false all-clear.
|
|
386
|
-
// warnings - hiding the management tools / promoting many (context bloat).
|
|
387
|
-
function apiSurface() {
|
|
388
|
-
const state = loadToolState(TOOL_STATE_PATH);
|
|
389
|
-
const metaNames = Object.values(META_TOOLS);
|
|
390
|
-
const meta = metaNames.map((name) => ({ name, hot: isToolHot(state, name, true) }));
|
|
391
|
-
|
|
392
|
-
let localIds = new Set();
|
|
393
|
-
try { localIds = new Set(loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT }).list().map((b) => b.id)); }
|
|
394
|
-
catch (_e) { /* report 0 promotions on a malformed register */ }
|
|
395
|
-
|
|
396
|
-
// Enabled expose[] entries are curated-direct: top-level every turn (no hot flag needed), so they
|
|
397
|
-
// count toward the bloat threshold exactly like a hot tool - statically enumerable, no discover.
|
|
398
|
-
// Collect their SURFACED names so a hot key that is ALSO an enabled-expose name isn't double-counted.
|
|
399
|
-
let curatedDirect = 0;
|
|
400
|
-
const exposedNames = new Set();
|
|
401
|
-
try {
|
|
402
|
-
const store = loadExposeStore(EXPOSE_PATH);
|
|
403
|
-
const exposed = store.listExposed({ enabledOnly: true });
|
|
404
|
-
curatedDirect = exposed.length;
|
|
405
|
-
for (const e of exposed) exposedNames.add(store.exposedName(e));
|
|
406
|
-
} catch (_e) { /* 0 on a malformed store */ }
|
|
407
|
-
|
|
408
|
-
let promotedLocal = 0;
|
|
409
|
-
let promotedOther = 0;
|
|
410
|
-
for (const key of Object.keys(state)) {
|
|
411
|
-
const e = state[key];
|
|
412
|
-
if (!e || e.hot !== true) continue; // only explicit promotions
|
|
413
|
-
if (metaNames.includes(key)) continue; // meta handled separately
|
|
414
|
-
if (e.enabled === false) continue; // disabled -> not actually on the surface
|
|
415
|
-
if (localIds.has(key)) promotedLocal += 1;
|
|
416
|
-
else if (exposedNames.has(key)) continue; // already counted as curated-direct (no double-count)
|
|
417
|
-
else promotedOther += 1;
|
|
418
|
-
}
|
|
419
|
-
const promotedTotal = promotedLocal + promotedOther + curatedDirect;
|
|
420
|
-
|
|
421
|
-
const warnings = [];
|
|
422
|
-
const listHot = (meta.find((m) => m.name === META_TOOLS.LIST) || {}).hot;
|
|
423
|
-
const runHot = (meta.find((m) => m.name === META_TOOLS.RUN) || {}).hot;
|
|
424
|
-
const metaOff = meta.filter((m) => !m.hot).length;
|
|
425
|
-
if (metaOff === metaNames.length) {
|
|
426
|
-
warnings.push('All four management tools are hidden from the top-level surface. A connected AI can no longer discover or run tools via the meta-tools - only the tools you have promoted are reachable. This is the "ordinary tools as an MCP" pattern; otherwise re-enable at least toolfunnel_list_tools and toolfunnel_run_tool.');
|
|
427
|
-
} else if (!listHot || !runHot) {
|
|
428
|
-
warnings.push('toolfunnel_list_tools and/or toolfunnel_run_tool is hidden - the AI may be unable to discover or run tools by name. Promote the specific tools it needs, or re-enable these meta-tools.');
|
|
429
|
-
}
|
|
430
|
-
if (promotedTotal > 10) {
|
|
431
|
-
warnings.push(promotedTotal + ' tools are promoted to every turn (including any enabled curated-direct expose entries). Each injects its schema into the model context on EVERY message - the opposite of the lean register. Promote only the few you call constantly; surface the rest leanly via toolfunnel_list_tools.');
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
return { meta, promotedLocal, promotedOther, curatedDirect, promotedTotal, warnings };
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// POST /api/tools/hook {id, event, on} -> read-modify-write hooks.manifest.json atomically.
|
|
438
|
-
function apiSetHook(body) {
|
|
439
|
-
const id = body && body.id;
|
|
440
|
-
const event = body && body.event;
|
|
441
|
-
const on = !!(body && body.on);
|
|
442
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
443
|
-
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
444
|
-
}
|
|
445
|
-
if (!VALID_HOOK_EVENTS.has(event)) {
|
|
446
|
-
return { status: 400, json: { ok: false, error: 'event must be "PreToolUse" or "PostToolUse"' } };
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// Resolve the tool's GATE NAME (register name, falling back to id). Reuse the registry so an
|
|
450
|
-
// unknown id is rejected cleanly rather than fabricating a manifest entry for nothing.
|
|
451
|
-
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
452
|
-
if (!registry.has(id)) {
|
|
453
|
-
return { status: 404, json: { ok: false, error: `unknown tool id "${id}"` } };
|
|
454
|
-
}
|
|
455
|
-
const gateName = gateNameFor(registry.getEntry(id));
|
|
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
|
-
|
|
473
|
-
const manifest = readManifest();
|
|
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
|
-
};
|
|
492
|
-
const eventLower = event.toLowerCase();
|
|
493
|
-
const scriptName = `${id}-${eventLower}.js`;
|
|
494
|
-
const scriptPath = path.join(HOOK_SCRIPTS_DIR, scriptName);
|
|
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
|
-
|
|
517
|
-
if (on) {
|
|
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
|
-
}
|
|
601
|
-
hooks.push({
|
|
602
|
-
event,
|
|
603
|
-
matcher: literal,
|
|
604
|
-
// The command carries the PORTABLE ${HOOKS_DIR} token (NOT interpolated here) - the
|
|
605
|
-
// hook-loader expands it to the absolute hooks dir at load time.
|
|
606
|
-
command: ourCommand,
|
|
607
|
-
enabled: true,
|
|
608
|
-
});
|
|
609
|
-
manifest.hooks = hooks;
|
|
610
|
-
atomicWriteJson(MANIFEST_PATH, manifest);
|
|
611
|
-
}
|
|
612
|
-
return {
|
|
613
|
-
status: 200,
|
|
614
|
-
json: {
|
|
615
|
-
ok: true,
|
|
616
|
-
scriptPath,
|
|
617
|
-
note:
|
|
618
|
-
`Create ${scriptName} at this path to define the ${event} behaviour. ` +
|
|
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).',
|
|
622
|
-
},
|
|
623
|
-
};
|
|
624
|
-
}
|
|
625
|
-
|
|
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) {
|
|
631
|
-
manifest.hooks = next;
|
|
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);
|
|
637
|
-
}
|
|
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 };
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// GET /api/upstreams -> { upstreams:[...], expose:[...] } (READ-ONLY for v1).
|
|
655
|
-
function apiUpstreams() {
|
|
656
|
-
const store = loadExposeStore(EXPOSE_PATH);
|
|
657
|
-
return {
|
|
658
|
-
upstreams: store.listUpstreams(),
|
|
659
|
-
expose: store.listExposed(),
|
|
660
|
-
// The active passthrough wrap (0.6.0) - null when the normal funnel surface is live.
|
|
661
|
-
wrapping: getPassthrough(loadToolState(TOOL_STATE_PATH)),
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/**
|
|
666
|
-
* POST /api/wrap - set or clear the PASSTHROUGH wrap. Body { upstream:'<id>' } wraps (that
|
|
667
|
-
* upstream's tools become the ENTIRE surface, meta-tools hidden, every call still gated);
|
|
668
|
-
* { upstream:null } clears. The UI is one of the two recovery paths from an AI-set wrap
|
|
669
|
-
* (the other is `toolfunnel wrap --off`), so this endpoint must never itself be funnelled.
|
|
670
|
-
*/
|
|
671
|
-
function apiWrap(body) {
|
|
672
|
-
// Sibling handlers wrap their store writes so the "handler never throws, returns clean
|
|
673
|
-
// {ok:false,error}" contract holds even on EACCES/ENOSPC (mirrors apiSetState).
|
|
674
|
-
try {
|
|
675
|
-
const b = body || {};
|
|
676
|
-
const id = b.upstream;
|
|
677
|
-
if (id === null || id === undefined || id === '') {
|
|
678
|
-
const was = getPassthrough(loadToolState(TOOL_STATE_PATH));
|
|
679
|
-
setPassthrough(TOOL_STATE_PATH, null);
|
|
680
|
-
return { status: 200, json: { ok: true, wrapping: null, was } };
|
|
681
|
-
}
|
|
682
|
-
if (typeof id !== 'string') {
|
|
683
|
-
return { status: 400, json: { ok: false, error: 'upstream must be an attached upstream id, or null to clear' } };
|
|
684
|
-
}
|
|
685
|
-
const store = loadExposeStore(EXPOSE_PATH);
|
|
686
|
-
const upstream = store.getUpstream(id);
|
|
687
|
-
if (!upstream) {
|
|
688
|
-
return { status: 404, json: { ok: false, error: 'no upstream with id "' + id + '"' } };
|
|
689
|
-
}
|
|
690
|
-
if (upstream.enabled === false) {
|
|
691
|
-
return { status: 400, json: { ok: false, error: 'upstream "' + id + '" is disabled - enable it first' } };
|
|
692
|
-
}
|
|
693
|
-
setPassthrough(TOOL_STATE_PATH, id);
|
|
694
|
-
// Wrap security notice - same check the CLI prints: wrapping SUSPENDS the path-isolation
|
|
695
|
-
// guard for this upstream, so outside paths are permitted by design. The UI operator gets
|
|
696
|
-
// the same informed consent the CLI operator gets.
|
|
697
|
-
let warning = null;
|
|
698
|
-
try {
|
|
699
|
-
const { looksLikePath, isInside } = require('../mcp/aggregator');
|
|
700
|
-
const outsideArgs = (Array.isArray(upstream.args) ? upstream.args : [])
|
|
701
|
-
.filter((a) => looksLikePath(a) && !isInside(root, a));
|
|
702
|
-
if (outsideArgs.length) {
|
|
703
|
-
warning = 'WRAP SECURITY NOTICE: this upstream uses paths outside the gateway root (' +
|
|
704
|
-
outsideArgs.join(', ') + '). While wrapped, the path-isolation guard is suspended for it - ' +
|
|
705
|
-
'the wrapped server can reach whatever those paths reach. Every call still passes the ' +
|
|
706
|
-
'PreToolUse gate; to restrict it, add a PreToolUse hook or disable individual tools. ' +
|
|
707
|
-
'See the manual, "Wrapping & security".';
|
|
708
|
-
}
|
|
709
|
-
} catch (_e) { /* the notice is best-effort - never block the wrap on it */ }
|
|
710
|
-
return { status: 200, json: { ok: true, wrapping: id, ...(warning ? { warning } : {}) } };
|
|
711
|
-
} catch (err) {
|
|
712
|
-
return { status: 500, json: { ok: false, error: (err && err.message) || 'wrap update failed' } };
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// ── Identity & ports (toolfunnel.json - 0.6.0 identity settings) ─────────────────────────────
|
|
717
|
-
|
|
718
|
-
// GET /api/identity -> { file, effective, restartRequired:true }. `file` is the raw
|
|
719
|
-
// toolfunnel.json content (absent/bad JSON -> {}), `effective` is what the gateway resolves
|
|
720
|
-
// from it (flag > config > default - same loader the gateway boots with).
|
|
721
|
-
function apiIdentity() {
|
|
722
|
-
let file = {};
|
|
723
|
-
try {
|
|
724
|
-
const parsed = JSON.parse(fs.readFileSync(TOOLFUNNEL_JSON, 'utf8'));
|
|
725
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) file = parsed;
|
|
726
|
-
} catch (_e) { /* absent or bad JSON - the loader treats it as defaults too */ }
|
|
727
|
-
let effective = null;
|
|
728
|
-
try { effective = loadServerConfig(root); } catch (_e) { /* tolerant loader; belt-and-braces */ }
|
|
729
|
-
return { file, effective, restartRequired: true };
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
// POST /api/identity - merge identity/port fields into toolfunnel.json (unknown fields in the
|
|
733
|
-
// file are PRESERVED). A string field set to '' removes it (falls back to the default); ports
|
|
734
|
-
// must be 1..65535 or '' to remove. Changes take effect on the gateway's NEXT start (the server
|
|
735
|
-
// config is read at boot), which the UI states - no silent expectation of a live change.
|
|
736
|
-
// Preserved fields are NOT re-validated here: an invalid-typed value already in the file (e.g.
|
|
737
|
-
// serverName:42) rides along untouched - deliberate, because loadServerConfig's per-field
|
|
738
|
-
// tolerance is the single defence layer and it falls back to the default for exactly such
|
|
739
|
-
// fields at boot. Re-validating on write would silently EDIT config the operator didn't touch.
|
|
740
|
-
function apiSetIdentity(body) {
|
|
741
|
-
const b = body || {};
|
|
742
|
-
const STR_FIELDS = ['serverName', 'serverVersion', 'clientName', 'clientVersion'];
|
|
743
|
-
const PORT_FIELDS = ['httpPort', 'uiPort'];
|
|
744
|
-
try {
|
|
745
|
-
let file = {};
|
|
746
|
-
try {
|
|
747
|
-
const parsed = JSON.parse(fs.readFileSync(TOOLFUNNEL_JSON, 'utf8'));
|
|
748
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) file = parsed;
|
|
749
|
-
} catch (_e) { /* start from empty */ }
|
|
750
|
-
for (const f of STR_FIELDS) {
|
|
751
|
-
if (!(f in b)) continue;
|
|
752
|
-
const v = b[f];
|
|
753
|
-
if (v === '' || v === null) { delete file[f]; continue; }
|
|
754
|
-
if (typeof v !== 'string' || v.trim().length === 0) {
|
|
755
|
-
return { status: 400, json: { ok: false, error: f + ' must be a non-empty string (or empty to reset)' } };
|
|
756
|
-
}
|
|
757
|
-
file[f] = v.trim();
|
|
758
|
-
}
|
|
759
|
-
for (const f of PORT_FIELDS) {
|
|
760
|
-
if (!(f in b)) continue;
|
|
761
|
-
const v = b[f];
|
|
762
|
-
if (v === '' || v === null) { delete file[f]; continue; }
|
|
763
|
-
const n = Number(v);
|
|
764
|
-
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
765
|
-
return { status: 400, json: { ok: false, error: f + ' must be an integer 1..65535 (or empty to reset)' } };
|
|
766
|
-
}
|
|
767
|
-
file[f] = n;
|
|
768
|
-
}
|
|
769
|
-
// serveLegacy (modern-only policy): only an EXPLICIT false ever lands in the file -
|
|
770
|
-
// matching the loader's explicit-false-only flip. true/'' removes the field (the
|
|
771
|
-
// serve-both default), so an untouched config never carries policy it didn't ask for.
|
|
772
|
-
if ('serveLegacy' in b) {
|
|
773
|
-
const v = b.serveLegacy;
|
|
774
|
-
if (v === false || v === 'false') file.serveLegacy = false;
|
|
775
|
-
else delete file.serveLegacy;
|
|
776
|
-
}
|
|
777
|
-
atomicWriteJson(TOOLFUNNEL_JSON, file);
|
|
778
|
-
return { status: 200, json: { ok: true, file, effective: loadServerConfig(root), restartRequired: true } };
|
|
779
|
-
} catch (err) {
|
|
780
|
-
return { status: 500, json: { ok: false, error: (err && err.message) || 'identity update failed' } };
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// GET /api/status -> { tools, upstreams, hooks }.
|
|
785
|
-
function apiStatus() {
|
|
786
|
-
let tools = 0;
|
|
787
|
-
let upstreams = 0;
|
|
788
|
-
let hooks = 0;
|
|
789
|
-
try {
|
|
790
|
-
tools = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT }).list().length;
|
|
791
|
-
} catch (_e) { /* report 0 on a malformed register */ }
|
|
792
|
-
try {
|
|
793
|
-
upstreams = loadExposeStore(EXPOSE_PATH).listUpstreams().length;
|
|
794
|
-
} catch (_e) { /* report 0 */ }
|
|
795
|
-
hooks = readManifest().hooks.length;
|
|
796
|
-
return { tools, upstreams, hooks };
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// ── Activity log (logger.js - toggleable JSONL; never throws) ────────────────────────────────────
|
|
800
|
-
// The logger is its own self-gating store (DEFAULT OFF). getConfig/tail are contracted never to
|
|
801
|
-
// throw; setConfig is the writer and CAN throw, so its handler wraps it into a clean 500.
|
|
802
|
-
|
|
803
|
-
// GET /api/logs -> { entries:[...] } the last 100 parsed records (file order, oldest->newest).
|
|
804
|
-
function apiLogs() {
|
|
805
|
-
let entries = [];
|
|
806
|
-
try {
|
|
807
|
-
const t = logger.tail(100);
|
|
808
|
-
entries = Array.isArray(t) ? t : [];
|
|
809
|
-
} catch (_e) { /* tail is contracted not to throw; stay empty on any surprise */ }
|
|
810
|
-
return { entries };
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// GET /api/logs/config -> { enabled, path } the resolved logger config (missing file -> disabled).
|
|
814
|
-
function apiLogsConfig() {
|
|
815
|
-
try {
|
|
816
|
-
return logger.getConfig();
|
|
817
|
-
} catch (_e) {
|
|
818
|
-
return { enabled: false, path: '' };
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// POST /api/logs/config { enabled?, path? } -> logger.setConfig (atomic merge). Validates field
|
|
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.
|
|
826
|
-
function apiSetLogsConfig(body) {
|
|
827
|
-
const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
828
|
-
const patch = {};
|
|
829
|
-
if (Object.prototype.hasOwnProperty.call(b, 'enabled')) {
|
|
830
|
-
if (typeof b.enabled !== 'boolean') {
|
|
831
|
-
return { status: 400, json: { ok: false, error: 'enabled must be a boolean' } };
|
|
832
|
-
}
|
|
833
|
-
patch.enabled = b.enabled;
|
|
834
|
-
}
|
|
835
|
-
if (Object.prototype.hasOwnProperty.call(b, 'path')) {
|
|
836
|
-
if (typeof b.path !== 'string' || b.path.length === 0) {
|
|
837
|
-
return { status: 400, json: { ok: false, error: 'path must be a non-empty string' } };
|
|
838
|
-
}
|
|
839
|
-
patch.path = b.path;
|
|
840
|
-
}
|
|
841
|
-
try {
|
|
842
|
-
const config = logger.setConfig(patch);
|
|
843
|
-
return { status: 200, json: { ok: true, config } };
|
|
844
|
-
} catch (err) {
|
|
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' } };
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// ── OAuth 2.1 (OPTIONAL; default OFF) - auth config + on-demand jose install ────────────────────
|
|
851
|
-
// No secrets here: issuer / audience / jwksUri are public identifiers (the JWKS itself is public),
|
|
852
|
-
// so the full config is safe to surface. `joseInstalled` + `configError` + `ready` drive the panel.
|
|
853
|
-
|
|
854
|
-
// GET /api/auth -> { config, joseInstalled, josePin, configError, ready }.
|
|
855
|
-
function apiAuth() {
|
|
856
|
-
let config;
|
|
857
|
-
try { config = authConfig.getConfig(); } catch (_e) { config = { enabled: false }; }
|
|
858
|
-
let jose = false;
|
|
859
|
-
try { jose = isJoseInstalled(); } catch (_e) { jose = false; }
|
|
860
|
-
let cfgErr = null;
|
|
861
|
-
try { cfgErr = authConfig.configError(config); } catch (_e) { cfgErr = null; }
|
|
862
|
-
// "ready" = if enabled, jose is present AND the config is coherent (the host would start).
|
|
863
|
-
const ready = !config.enabled || (jose && !cfgErr);
|
|
864
|
-
return { config, joseInstalled: jose, josePin: JOSE_PIN, configError: cfgErr, ready };
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
// POST /api/auth/config { enabled?, issuer?, audience?, jwksUri?, algorithms?, requiredScopes?,
|
|
868
|
-
// clockToleranceSec? } -> validate field types (clean 400 on a bad shape) then authConfig.setConfig
|
|
869
|
-
// (atomic merge). Permissive about partial configuration (you may set issuer now, audience later);
|
|
870
|
-
// the response reports joseInstalled + configError + ready so the UI can warn that an ENABLED but
|
|
871
|
-
// not-yet-ready config will be refused by the HTTP host at start.
|
|
872
|
-
function apiSetAuthConfig(body) {
|
|
873
|
-
const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
874
|
-
const patch = {};
|
|
875
|
-
const bad = (msg) => ({ status: 400, json: { ok: false, error: msg } });
|
|
876
|
-
|
|
877
|
-
if (Object.prototype.hasOwnProperty.call(b, 'enabled')) {
|
|
878
|
-
if (typeof b.enabled !== 'boolean') return bad('enabled must be a boolean');
|
|
879
|
-
patch.enabled = b.enabled;
|
|
880
|
-
}
|
|
881
|
-
for (const f of ['issuer', 'audience', 'jwksUri']) {
|
|
882
|
-
if (Object.prototype.hasOwnProperty.call(b, f)) {
|
|
883
|
-
if (typeof b[f] !== 'string') return bad(f + ' must be a string');
|
|
884
|
-
patch[f] = b[f];
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
for (const f of ['algorithms', 'requiredScopes']) {
|
|
888
|
-
if (Object.prototype.hasOwnProperty.call(b, f)) {
|
|
889
|
-
if (!Array.isArray(b[f]) || b[f].some((x) => typeof x !== 'string')) return bad(f + ' must be an array of strings');
|
|
890
|
-
patch[f] = b[f];
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
if (Object.prototype.hasOwnProperty.call(b, 'clockToleranceSec')) {
|
|
894
|
-
if (typeof b.clockToleranceSec !== 'number' || !Number.isFinite(b.clockToleranceSec) || b.clockToleranceSec < 0) {
|
|
895
|
-
return bad('clockToleranceSec must be a non-negative number');
|
|
896
|
-
}
|
|
897
|
-
patch.clockToleranceSec = b.clockToleranceSec;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
// Guard the classic footgun: enabling auth with no jose installed would make the HTTP host refuse
|
|
901
|
-
// to start. Block it with an actionable message rather than writing a config that breaks the host.
|
|
902
|
-
if (patch.enabled === true && !isJoseInstalled()) {
|
|
903
|
-
return bad('install the OAuth dependency first (click Install, or run `toolfunnel install-oauth`) before enabling auth');
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
let config;
|
|
907
|
-
try {
|
|
908
|
-
config = authConfig.setConfig(patch);
|
|
909
|
-
} catch (err) {
|
|
910
|
-
return { status: 500, json: { ok: false, error: (err && err.message) || 'auth config write failed' } };
|
|
911
|
-
}
|
|
912
|
-
const cfgErr = authConfig.configError(config);
|
|
913
|
-
const ready = !config.enabled || (isJoseInstalled() && !cfgErr);
|
|
914
|
-
return { status: 200, json: { ok: true, config, joseInstalled: isJoseInstalled(), configError: cfgErr, ready } };
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
// POST /api/oauth/install -> install the single optional dependency (jose@PIN) on demand. Async +
|
|
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;
|
|
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() {
|
|
939
|
-
let res;
|
|
940
|
-
try {
|
|
941
|
-
res = await installJose();
|
|
942
|
-
} catch (err) {
|
|
943
|
-
return { status: 500, json: { ok: false, error: (err && err.message) || 'install failed' } };
|
|
944
|
-
}
|
|
945
|
-
// Trim npm's chatty output so the panel shows a useful tail without flooding the response.
|
|
946
|
-
const tail = (s) => (typeof s === 'string' ? s.slice(-2000) : '');
|
|
947
|
-
return {
|
|
948
|
-
status: res.ok ? 200 : 500,
|
|
949
|
-
json: { ok: res.ok, message: res.message, code: res.code, stdout: tail(res.stdout), stderr: tail(res.stderr), joseInstalled: isJoseInstalled() },
|
|
950
|
-
};
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
// ── Tool register writes (registry.js - atomic via the store) ──────────────────────────────────
|
|
954
|
-
|
|
955
|
-
// POST /api/tools/add {entry} -> registry.add. When entry.scriptText is supplied for a SCRIPT
|
|
956
|
-
// invoke the body is authored under tools/scripts/<basename> FIRST (registry.writeScript, which
|
|
957
|
-
// is path-guarded), then the entry is registered. registry.add validates id/name/invoke/mode and
|
|
958
|
-
// rejects a duplicate id. Never throws - a bad shape becomes a clean 400.
|
|
959
|
-
function apiToolAdd(body) {
|
|
960
|
-
const entry = body && body.entry;
|
|
961
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
962
|
-
return { status: 400, json: { ok: false, error: 'entry (object) is required' } };
|
|
963
|
-
}
|
|
964
|
-
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
965
|
-
try {
|
|
966
|
-
const scriptText = entry.scriptText;
|
|
967
|
-
const clean = { ...entry };
|
|
968
|
-
delete clean.scriptText; // scriptText is a UI affordance, not a register field
|
|
969
|
-
// Author the script body before registering, so a script invoke points at a real file.
|
|
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;
|
|
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
|
-
}
|
|
989
|
-
}
|
|
990
|
-
const added = registry.add(clean);
|
|
991
|
-
return { status: 200, json: { ok: true, entry: added } };
|
|
992
|
-
} catch (err) {
|
|
993
|
-
return { status: 400, json: { ok: false, error: (err && err.message) || 'tool add failed' } };
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
// POST /api/tools/remove {id} -> registry.remove + clearToolState (drop the enabled/hidden overlay
|
|
998
|
-
// key so a re-added id starts clean). Unknown id -> 404.
|
|
999
|
-
function apiToolRemove(body) {
|
|
1000
|
-
const id = body && body.id;
|
|
1001
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
1002
|
-
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1003
|
-
}
|
|
1004
|
-
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
1005
|
-
try {
|
|
1006
|
-
registry.remove(id);
|
|
1007
|
-
} catch (err) {
|
|
1008
|
-
return { status: 404, json: { ok: false, error: (err && err.message) || 'tool remove failed' } };
|
|
1009
|
-
}
|
|
1010
|
-
// Best-effort overlay cleanup - a failure here must not undo the register removal.
|
|
1011
|
-
try { clearToolState(TOOL_STATE_PATH, id); } catch (_e) { /* overlay cleanup is best-effort */ }
|
|
1012
|
-
return { status: 200, json: { ok: true } };
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
// POST /api/tools/mode {id, mode} -> registry.update(id,{mode}). Switching a reference tool that has
|
|
1016
|
-
// no invoke to "gateway" fails validation (nothing to run) and surfaces as a clean 400.
|
|
1017
|
-
function apiToolMode(body) {
|
|
1018
|
-
const id = body && body.id;
|
|
1019
|
-
const mode = body && body.mode;
|
|
1020
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
1021
|
-
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1022
|
-
}
|
|
1023
|
-
if (mode !== 'reference' && mode !== 'gateway') {
|
|
1024
|
-
return { status: 400, json: { ok: false, error: 'mode must be "reference" or "gateway"' } };
|
|
1025
|
-
}
|
|
1026
|
-
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
1027
|
-
try {
|
|
1028
|
-
const updated = registry.update(id, { mode });
|
|
1029
|
-
return { status: 200, json: { ok: true, mode: resolveMode(updated) } };
|
|
1030
|
-
} catch (err) {
|
|
1031
|
-
return { status: 400, json: { ok: false, error: (err && err.message) || 'tool mode update failed' } };
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
// GET /api/tools/detail?id=<id> -> the FULL entry (name, summary, category, instructions, invoke,
|
|
1036
|
-
// mode) plus the script BODY for a script invoke, so the UI can show + edit everything about one
|
|
1037
|
-
// tool. Read fresh; unknown id -> 404.
|
|
1038
|
-
function apiToolDetail(id) {
|
|
1039
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
1040
|
-
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1041
|
-
}
|
|
1042
|
-
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
1043
|
-
let entry;
|
|
1044
|
-
try {
|
|
1045
|
-
entry = registry.getEntry(id);
|
|
1046
|
-
} catch (err) {
|
|
1047
|
-
return { status: 404, json: { ok: false, error: (err && err.message) || `unknown tool id "${id}"` } };
|
|
1048
|
-
}
|
|
1049
|
-
// For a script invoke, read the body so the user can view/edit it. Resolve strictly inside the
|
|
1050
|
-
// scripts root (defence-in-depth via basename); a missing/unreadable file leaves scriptText null.
|
|
1051
|
-
let scriptText = null;
|
|
1052
|
-
try {
|
|
1053
|
-
if (entry.invoke && entry.invoke.type === 'script' && entry.invoke.path) {
|
|
1054
|
-
const sp = path.join(SCRIPTS_ROOT, path.basename(entry.invoke.path));
|
|
1055
|
-
if (fs.existsSync(sp)) scriptText = fs.readFileSync(sp, 'utf8');
|
|
1056
|
-
}
|
|
1057
|
-
} catch (_e) { /* leave scriptText null on any read failure */ }
|
|
1058
|
-
return { status: 200, json: { ok: true, entry, scriptText } };
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
// POST /api/tools/update {id, patch:{name?,summary?,category?,instructions?,mode?,invoke?}, scriptText?}
|
|
1062
|
-
// -> registry.update (shallow-merge + re-validate + atomic persist). When scriptText is supplied for
|
|
1063
|
-
// a script invoke, the body is authored under tools/scripts/ (path-guarded) first. A bad shape /
|
|
1064
|
-
// unknown id surfaces as a clean 400/404 (registry.update throws -> caught).
|
|
1065
|
-
function apiToolUpdate(body) {
|
|
1066
|
-
const id = body && body.id;
|
|
1067
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
1068
|
-
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1069
|
-
}
|
|
1070
|
-
const patch = body && body.patch && typeof body.patch === 'object' && !Array.isArray(body.patch) ? body.patch : {};
|
|
1071
|
-
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
1072
|
-
if (!registry.has(id)) {
|
|
1073
|
-
return { status: 404, json: { ok: false, error: `unknown tool id "${id}"` } };
|
|
1074
|
-
}
|
|
1075
|
-
try {
|
|
1076
|
-
// Author the script body first (if provided) so the file matches the (possibly updated) invoke.
|
|
1077
|
-
if (typeof body.scriptText === 'string') {
|
|
1078
|
-
const inv = (patch.invoke && patch.invoke.type) ? patch.invoke : registry.getEntry(id).invoke;
|
|
1079
|
-
if (inv && inv.type === 'script' && inv.path) registry.writeScript(inv.path, body.scriptText);
|
|
1080
|
-
}
|
|
1081
|
-
const updated = registry.update(id, patch);
|
|
1082
|
-
return { status: 200, json: { ok: true, entry: updated } };
|
|
1083
|
-
} catch (err) {
|
|
1084
|
-
return { status: 400, json: { ok: false, error: (err && err.message) || 'tool update failed' } };
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
// ── Hook manifest writes (hook-loader.js - atomic; overlay-aware) ───────────────────────────────
|
|
1089
|
-
|
|
1090
|
-
/** The relative scripts/<file> a hook command references, if any (so a scriptText lands where the
|
|
1091
|
-
* command will run it). Returns null when the command names no scripts/ file. */
|
|
1092
|
-
function deriveHookScriptRel(command) {
|
|
1093
|
-
if (typeof command !== 'string') return null;
|
|
1094
|
-
const m = command.match(/scripts[\\/]([A-Za-z0-9._-]+)/);
|
|
1095
|
-
return m ? 'scripts/' + m[1] : null;
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
// GET /api/hooks -> { hooks:[{ id,event,matcher,enabled,description }] }. loadManifest applies the
|
|
1099
|
-
// hooks.state.json overlay over the manifest seed, so `enabled` is the LIVE state. Tool Pre/Post
|
|
1100
|
-
// gates authored via /api/tools/hook have no id and are surfaced with id:"" (managed on the Tools tab).
|
|
1101
|
-
function apiHooks() {
|
|
1102
|
-
let loader;
|
|
1103
|
-
try {
|
|
1104
|
-
loader = loadManifest(MANIFEST_PATH);
|
|
1105
|
-
} catch (_e) {
|
|
1106
|
-
return { hooks: [] };
|
|
1107
|
-
}
|
|
1108
|
-
return {
|
|
1109
|
-
hooks: loader.hooks.map((h) => ({
|
|
1110
|
-
id: typeof h.id === 'string' ? h.id : '',
|
|
1111
|
-
event: h.event || '',
|
|
1112
|
-
matcher: h.matcher == null ? '' : String(h.matcher),
|
|
1113
|
-
enabled: h.enabled === true,
|
|
1114
|
-
description: h.description || '',
|
|
1115
|
-
})),
|
|
1116
|
-
};
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
// POST /api/hooks/add {entry} -> hookLoader.addEntry (validates id/event/command + rejects dup id).
|
|
1120
|
-
// When entry.scriptText is supplied the body is authored under hooks/scripts/ AFTER the entry exists
|
|
1121
|
-
// (writeScript resolves the path from the entry's `script`, derived from the command if not given).
|
|
1122
|
-
function apiHookAdd(body) {
|
|
1123
|
-
const entry = body && body.entry;
|
|
1124
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
1125
|
-
return { status: 400, json: { ok: false, error: 'entry (object) is required' } };
|
|
1126
|
-
}
|
|
1127
|
-
let loader;
|
|
1128
|
-
try {
|
|
1129
|
-
loader = loadManifest(MANIFEST_PATH);
|
|
1130
|
-
} catch (err) {
|
|
1131
|
-
return { status: 500, json: { ok: false, error: 'cannot read hook manifest: ' + ((err && err.message) || err) } };
|
|
1132
|
-
}
|
|
1133
|
-
try {
|
|
1134
|
-
const scriptText = entry.scriptText;
|
|
1135
|
-
const spec = { ...entry };
|
|
1136
|
-
delete spec.scriptText;
|
|
1137
|
-
// If a script body is supplied but no explicit `script`, derive it from the command so the
|
|
1138
|
-
// file written is the one the command runs.
|
|
1139
|
-
if (typeof scriptText === 'string' && scriptText.length > 0 && (typeof spec.script !== 'string' || !spec.script)) {
|
|
1140
|
-
const rel = deriveHookScriptRel(spec.command);
|
|
1141
|
-
if (rel) spec.script = rel;
|
|
1142
|
-
}
|
|
1143
|
-
loader.addEntry(spec); // validates id/event/command; throws on duplicate id
|
|
1144
|
-
if (typeof scriptText === 'string' && scriptText.length > 0) {
|
|
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
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
return { status: 200, json: { ok: true, id: spec.id } };
|
|
1156
|
-
} catch (err) {
|
|
1157
|
-
return { status: 400, json: { ok: false, error: (err && err.message) || 'hook add failed' } };
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
// POST /api/hooks/state {id, action} -> enable/disable (setEnabled, overlay-backed) or remove
|
|
1162
|
-
// (removeEntry). Unknown id -> 404. setEnabled does not itself verify existence, so we check first
|
|
1163
|
-
// to avoid writing a stray overlay key for a non-existent hook.
|
|
1164
|
-
function apiHookState(body) {
|
|
1165
|
-
const id = body && body.id;
|
|
1166
|
-
const action = body && body.action;
|
|
1167
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
1168
|
-
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1169
|
-
}
|
|
1170
|
-
if (action !== 'enable' && action !== 'disable' && action !== 'remove') {
|
|
1171
|
-
return { status: 400, json: { ok: false, error: 'action must be "enable", "disable", or "remove"' } };
|
|
1172
|
-
}
|
|
1173
|
-
let loader;
|
|
1174
|
-
try {
|
|
1175
|
-
loader = loadManifest(MANIFEST_PATH);
|
|
1176
|
-
} catch (err) {
|
|
1177
|
-
return { status: 500, json: { ok: false, error: 'cannot read hook manifest: ' + ((err && err.message) || err) } };
|
|
1178
|
-
}
|
|
1179
|
-
try {
|
|
1180
|
-
if (action === 'remove') {
|
|
1181
|
-
const removed = loader.removeEntry(id);
|
|
1182
|
-
if (!removed) {
|
|
1183
|
-
return { status: 404, json: { ok: false, error: `unknown hook id "${id}"` } };
|
|
1184
|
-
}
|
|
1185
|
-
} else {
|
|
1186
|
-
if (!loader.getSpec(id)) {
|
|
1187
|
-
return { status: 404, json: { ok: false, error: `unknown hook id "${id}"` } };
|
|
1188
|
-
}
|
|
1189
|
-
loader.setEnabled(id, action === 'enable');
|
|
1190
|
-
}
|
|
1191
|
-
return { status: 200, json: { ok: true } };
|
|
1192
|
-
} catch (err) {
|
|
1193
|
-
return { status: 400, json: { ok: false, error: (err && err.message) || 'hook state update failed' } };
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
// ── MCP upstream / expose writes (expose-store.js - atomic) ─────────────────────────────────────
|
|
1198
|
-
|
|
1199
|
-
// POST /api/mcp/add {upstream, expose?:[...]} -> addUpstream then addExpose per item. addUpstream
|
|
1200
|
-
// validates a unique non-empty id, transport, and a stdio command. Each expose item is keyed to the
|
|
1201
|
-
// new upstream id.
|
|
1202
|
-
function apiMcpAdd(body) {
|
|
1203
|
-
const upstream = body && body.upstream;
|
|
1204
|
-
if (!upstream || typeof upstream !== 'object' || Array.isArray(upstream)) {
|
|
1205
|
-
return { status: 400, json: { ok: false, error: 'upstream (object) is required' } };
|
|
1206
|
-
}
|
|
1207
|
-
const store = loadExposeStore(EXPOSE_PATH);
|
|
1208
|
-
try {
|
|
1209
|
-
const added = store.addUpstream(upstream);
|
|
1210
|
-
const items = Array.isArray(body && body.expose) ? body.expose : [];
|
|
1211
|
-
const exposed = [];
|
|
1212
|
-
for (const item of items) {
|
|
1213
|
-
exposed.push(store.addExpose({ ...item, upstream: added.id }));
|
|
1214
|
-
}
|
|
1215
|
-
return { status: 200, json: { ok: true, upstream: added, expose: exposed } };
|
|
1216
|
-
} catch (err) {
|
|
1217
|
-
return { status: 400, json: { ok: false, error: (err && err.message) || 'mcp add failed' } };
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
// POST /api/mcp/state {id, action} -> enable/disable (setUpstreamEnabled) or remove (removeUpstream,
|
|
1222
|
-
// which cascades the upstream's expose entries). Unknown id -> 404.
|
|
1223
|
-
function apiMcpState(body) {
|
|
1224
|
-
const id = body && body.id;
|
|
1225
|
-
const action = body && body.action;
|
|
1226
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
1227
|
-
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1228
|
-
}
|
|
1229
|
-
if (action !== 'enable' && action !== 'disable' && action !== 'remove' && action !== 'pin' && action !== 'unpin') {
|
|
1230
|
-
return { status: 400, json: { ok: false, error: 'action must be "enable", "disable", "remove", "pin", or "unpin"' } };
|
|
1231
|
-
}
|
|
1232
|
-
const store = loadExposeStore(EXPOSE_PATH);
|
|
1233
|
-
try {
|
|
1234
|
-
if (action === 'remove') {
|
|
1235
|
-
store.removeUpstream(id);
|
|
1236
|
-
} else if (action === 'pin' || action === 'unpin') {
|
|
1237
|
-
// legacyPin (0.6.0): keep speaking MCP 2024-11-05 to this upstream forever - opt-in,
|
|
1238
|
-
// warns at startup and per call. Takes effect on the upstream's NEXT (re)connect.
|
|
1239
|
-
store.updateUpstream(id, { legacyPin: action === 'pin' });
|
|
1240
|
-
return { status: 200, json: { ok: true, legacyPin: action === 'pin' } };
|
|
1241
|
-
} else {
|
|
1242
|
-
store.setUpstreamEnabled(id, action === 'enable');
|
|
1243
|
-
}
|
|
1244
|
-
// Disabling/removing the WRAPPED upstream would brick the surface (empty list, no in-band
|
|
1245
|
-
// recovery) - auto-clear the wrap so the funnel comes back.
|
|
1246
|
-
let wrapCleared = false;
|
|
1247
|
-
if (action !== 'enable') {
|
|
1248
|
-
try {
|
|
1249
|
-
if (getPassthrough(loadToolState(TOOL_STATE_PATH)) === id) {
|
|
1250
|
-
setPassthrough(TOOL_STATE_PATH, null);
|
|
1251
|
-
wrapCleared = true;
|
|
1252
|
-
}
|
|
1253
|
-
} catch (_e) { /* never let wrap-cleanup sink the primary action */ }
|
|
1254
|
-
}
|
|
1255
|
-
return { status: 200, json: { ok: true, ...(wrapCleared ? { wrapCleared: true } : {}) } };
|
|
1256
|
-
} catch (err) {
|
|
1257
|
-
return { status: 404, json: { ok: false, error: (err && err.message) || 'mcp state update failed' } };
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
// POST /api/mcp/discover {id} -> LIVE connect + tools/list of one upstream (the "Test / Discover"
|
|
1262
|
-
// button). Constructs a throwaway Aggregator over the SAME store (so the isolation guard applies),
|
|
1263
|
-
// connects + lists, then closes it. Returns each discovered tool with its SURFACED name (an enabled
|
|
1264
|
-
// expose `as` else `<id>_<tool>` - exactly the name the lean list / matrix use) and its current
|
|
1265
|
-
// lean (enabled) + hot state, so the UI can curate per upstream tool. A connect/list failure (bad
|
|
1266
|
-
// command, crash, isolation) is a real user-facing outcome -> 200 { ok:false, error }, not a 500.
|
|
1267
|
-
async function apiMcpDiscover(body) {
|
|
1268
|
-
const id = body && body.id;
|
|
1269
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
1270
|
-
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1271
|
-
}
|
|
1272
|
-
const store = loadExposeStore(EXPOSE_PATH);
|
|
1273
|
-
if (!store.getUpstream(id)) {
|
|
1274
|
-
return { status: 404, json: { ok: false, error: `unknown upstream "${id}"` } };
|
|
1275
|
-
}
|
|
1276
|
-
// Same wrap exemption as the live gateway: the WRAPPED upstream may reference paths outside
|
|
1277
|
-
// the root (transparent-wrapper mode). Without this provider the throwaway Aggregator
|
|
1278
|
-
// hard-refused exactly the upstreams the wrap security notice tells operators to curate
|
|
1279
|
-
// HERE - per-tool enabled:false via this discover.
|
|
1280
|
-
const agg = new Aggregator({
|
|
1281
|
-
store, gatewayRoot: root,
|
|
1282
|
-
wrapTargetProvider: () => {
|
|
1283
|
-
try { return getPassthrough(loadToolState(TOOL_STATE_PATH)); } catch (_e) { return null; }
|
|
1284
|
-
},
|
|
1285
|
-
});
|
|
1286
|
-
let tools;
|
|
1287
|
-
try {
|
|
1288
|
-
// discover() connects the upstream lazily (isolation-guarded), lists its tools, caches+returns.
|
|
1289
|
-
// Race a timeout so a wedged upstream never hangs the UI request (closeAll reaps the child).
|
|
1290
|
-
tools = await withTimeout(agg.discover(id), 20000, `discover "${id}" timed out`);
|
|
1291
|
-
} catch (err) {
|
|
1292
|
-
try { await agg.closeAll(); } catch (_e) { /* best-effort teardown */ }
|
|
1293
|
-
return { status: 200, json: { ok: false, id, error: (err && err.message) || 'discover failed' } };
|
|
1294
|
-
}
|
|
1295
|
-
try { await agg.closeAll(); } catch (_e) { /* best-effort teardown */ }
|
|
1296
|
-
|
|
1297
|
-
const state = loadToolState(TOOL_STATE_PATH);
|
|
1298
|
-
const enabledExposed = store.listExposed({ upstream: id, enabledOnly: true });
|
|
1299
|
-
const items = (Array.isArray(tools) ? tools : []).map((t) => {
|
|
1300
|
-
const surfaced = surfacedNameFor(store, enabledExposed, id, t && t.name);
|
|
1301
|
-
return {
|
|
1302
|
-
tool: (t && t.name) || '',
|
|
1303
|
-
name: surfaced,
|
|
1304
|
-
description: (t && typeof t.description === 'string') ? t.description : '',
|
|
1305
|
-
enabled: isToolEnabled(state, surfaced),
|
|
1306
|
-
hot: isToolHot(state, surfaced, false),
|
|
1307
|
-
};
|
|
1308
|
-
});
|
|
1309
|
-
return { status: 200, json: { ok: true, id, tools: items } };
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
// The POST routing table - path -> handler. Each handler takes the parsed body and returns
|
|
1313
|
-
// { status, json } (or a Promise of it - the dispatcher awaits); none throws (every store call is
|
|
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
|
-
};
|
|
1328
|
-
const POST_HANDLERS = {
|
|
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.
|
|
1340
|
-
'/api/mcp/discover': apiMcpDiscover,
|
|
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).
|
|
1347
|
-
'/api/oauth/install': apiOauthInstall,
|
|
1348
|
-
};
|
|
1349
|
-
|
|
1350
|
-
// ── HTTP plumbing ───────────────────────────────────────────────────────────────────────────
|
|
1351
|
-
|
|
1352
|
-
/** Read a request body up to MAX_BODY_BYTES, then JSON.parse. Resolves {} for an empty body. */
|
|
1353
|
-
function readJsonBody(req) {
|
|
1354
|
-
return new Promise((resolve, reject) => {
|
|
1355
|
-
let size = 0;
|
|
1356
|
-
const chunks = [];
|
|
1357
|
-
let done = false;
|
|
1358
|
-
const finish = (fn, val) => { if (!done) { done = true; fn(val); } };
|
|
1359
|
-
req.on('data', (chunk) => {
|
|
1360
|
-
size += chunk.length;
|
|
1361
|
-
if (size > MAX_BODY_BYTES) {
|
|
1362
|
-
finish(reject, new Error('request body too large'));
|
|
1363
|
-
try { req.destroy(); } catch (_e) { /* ignore */ }
|
|
1364
|
-
return;
|
|
1365
|
-
}
|
|
1366
|
-
chunks.push(chunk);
|
|
1367
|
-
});
|
|
1368
|
-
req.on('end', () => {
|
|
1369
|
-
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
1370
|
-
if (raw.length === 0) return finish(resolve, {});
|
|
1371
|
-
try {
|
|
1372
|
-
finish(resolve, JSON.parse(raw));
|
|
1373
|
-
} catch (_e) {
|
|
1374
|
-
finish(reject, new Error('invalid JSON body'));
|
|
1375
|
-
}
|
|
1376
|
-
});
|
|
1377
|
-
req.on('error', (err) => finish(reject, err));
|
|
1378
|
-
});
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
/** Write a JSON response. Defensive: a serialise failure degrades to a 500 text body. */
|
|
1382
|
-
function sendJson(res, status, obj) {
|
|
1383
|
-
let bodyStr;
|
|
1384
|
-
try {
|
|
1385
|
-
bodyStr = JSON.stringify(obj);
|
|
1386
|
-
} catch (_e) {
|
|
1387
|
-
bodyStr = '{"ok":false,"error":"failed to serialise response"}';
|
|
1388
|
-
status = 500;
|
|
1389
|
-
}
|
|
1390
|
-
res.writeHead(status, {
|
|
1391
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
1392
|
-
'Content-Length': Buffer.byteLength(bodyStr, 'utf8'),
|
|
1393
|
-
'Cache-Control': 'no-store',
|
|
1394
|
-
});
|
|
1395
|
-
res.end(bodyStr);
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
/** Serve a static file from PUBLIC_DIR (read FRESH). 404 JSON when absent / outside the dir. */
|
|
1399
|
-
function sendStatic(res, relName) {
|
|
1400
|
-
// Resolve strictly inside PUBLIC_DIR (defence-in-depth against traversal).
|
|
1401
|
-
const resolved = path.resolve(PUBLIC_DIR, relName);
|
|
1402
|
-
const baseWithSep = PUBLIC_DIR.endsWith(path.sep) ? PUBLIC_DIR : PUBLIC_DIR + path.sep;
|
|
1403
|
-
if (resolved !== PUBLIC_DIR && !resolved.startsWith(baseWithSep)) {
|
|
1404
|
-
return sendJson(res, 403, { ok: false, error: 'forbidden' });
|
|
1405
|
-
}
|
|
1406
|
-
let data;
|
|
1407
|
-
try {
|
|
1408
|
-
data = fs.readFileSync(resolved);
|
|
1409
|
-
} catch (_e) {
|
|
1410
|
-
return sendJson(res, 404, { ok: false, error: 'not found: ' + relName });
|
|
1411
|
-
}
|
|
1412
|
-
const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream';
|
|
1413
|
-
res.writeHead(200, {
|
|
1414
|
-
'Content-Type': type,
|
|
1415
|
-
'Content-Length': data.length,
|
|
1416
|
-
'Cache-Control': 'no-store',
|
|
1417
|
-
});
|
|
1418
|
-
res.end(data);
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
/** The single request listener. Dispatches by method + path. NEVER throws. */
|
|
1422
|
-
async function onRequest(req, res) {
|
|
1423
|
-
try {
|
|
1424
|
-
// DNS-rebinding / non-loopback guard (defence-in-depth on top of the bind address).
|
|
1425
|
-
if (!isLoopbackHost(req.headers && req.headers.host)) {
|
|
1426
|
-
return sendJson(res, 403, { ok: false, error: 'forbidden: non-loopback Host' });
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
const rawUrl = req.url || '/';
|
|
1430
|
-
const qIndex = rawUrl.indexOf('?');
|
|
1431
|
-
const pathName = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
|
|
1432
|
-
const method = (req.method || 'GET').toUpperCase();
|
|
1433
|
-
|
|
1434
|
-
// CSRF guard. The bind address stops the NETWORK, not the browser: any web page the
|
|
1435
|
-
// operator visits can fire a no-cors POST at 127.0.0.1:<port>, and the mutating routes
|
|
1436
|
-
// below write tool scripts and register process-spawning upstreams. Browsers ALWAYS send
|
|
1437
|
-
// an Origin header on cross-origin POSTs, so: a PRESENT Origin must itself be loopback
|
|
1438
|
-
// (the UI's own pages), while an ABSENT Origin (curl, CLI clients, non-browser tools) is
|
|
1439
|
-
// allowed - for those, the bind address remains the boundary. GETs stay open (read-only).
|
|
1440
|
-
if (method !== 'GET') {
|
|
1441
|
-
const origin = req.headers && req.headers.origin;
|
|
1442
|
-
if (origin) {
|
|
1443
|
-
let originHost = null;
|
|
1444
|
-
try { originHost = new URL(origin).host; } catch (_e) { /* malformed -> reject below */ }
|
|
1445
|
-
if (!originHost || !isLoopbackHost(originHost)) {
|
|
1446
|
-
return sendJson(res, 403, { ok: false, error: 'forbidden: cross-origin request (CSRF guard)' });
|
|
1447
|
-
}
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
// ── Static assets ─────────────────────────────────────────────────────────────────────────
|
|
1452
|
-
if (method === 'GET' && (pathName === '/' || pathName === '/index.html')) {
|
|
1453
|
-
return sendStatic(res, 'index.html');
|
|
1454
|
-
}
|
|
1455
|
-
if (method === 'GET' && pathName === '/app.js') {
|
|
1456
|
-
return sendStatic(res, 'app.js');
|
|
1457
|
-
}
|
|
1458
|
-
if (method === 'GET' && pathName === '/styles.css') {
|
|
1459
|
-
return sendStatic(res, 'styles.css');
|
|
1460
|
-
}
|
|
1461
|
-
// Image assets (logo, favicon, ...): serve any safe-named image file from public/. The filename
|
|
1462
|
-
// regex + sendStatic's traversal guard keep this strictly inside the public dir.
|
|
1463
|
-
if (method === 'GET' && /^\/[A-Za-z0-9._-]+\.(jpg|jpeg|png|svg|ico|webp)$/.test(pathName)) {
|
|
1464
|
-
return sendStatic(res, pathName.slice(1));
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
// ── JSON API (read fresh each call) ─────────────────────────────────────────────────────────
|
|
1468
|
-
if (method === 'GET' && pathName === '/api/tools') {
|
|
1469
|
-
return sendJson(res, 200, apiTools());
|
|
1470
|
-
}
|
|
1471
|
-
if (method === 'GET' && pathName === '/api/tools/detail') {
|
|
1472
|
-
let id = '';
|
|
1473
|
-
try {
|
|
1474
|
-
const qi = rawUrl.indexOf('?');
|
|
1475
|
-
id = qi === -1 ? '' : (new URLSearchParams(rawUrl.slice(qi + 1)).get('id') || '');
|
|
1476
|
-
} catch (_e) { id = ''; }
|
|
1477
|
-
const d = apiToolDetail(id);
|
|
1478
|
-
return sendJson(res, d.status, d.json);
|
|
1479
|
-
}
|
|
1480
|
-
if (method === 'GET' && pathName === '/api/upstreams') {
|
|
1481
|
-
return sendJson(res, 200, apiUpstreams());
|
|
1482
|
-
}
|
|
1483
|
-
if (method === 'GET' && pathName === '/api/identity') {
|
|
1484
|
-
return sendJson(res, 200, apiIdentity());
|
|
1485
|
-
}
|
|
1486
|
-
if (method === 'GET' && pathName === '/api/hooks') {
|
|
1487
|
-
return sendJson(res, 200, apiHooks());
|
|
1488
|
-
}
|
|
1489
|
-
if (method === 'GET' && pathName === '/api/status') {
|
|
1490
|
-
return sendJson(res, 200, apiStatus());
|
|
1491
|
-
}
|
|
1492
|
-
if (method === 'GET' && pathName === '/api/surface') {
|
|
1493
|
-
return sendJson(res, 200, apiSurface());
|
|
1494
|
-
}
|
|
1495
|
-
if (method === 'GET' && pathName === '/api/logs') {
|
|
1496
|
-
return sendJson(res, 200, apiLogs());
|
|
1497
|
-
}
|
|
1498
|
-
if (method === 'GET' && pathName === '/api/logs/config') {
|
|
1499
|
-
return sendJson(res, 200, apiLogsConfig());
|
|
1500
|
-
}
|
|
1501
|
-
if (method === 'GET' && pathName === '/api/auth') {
|
|
1502
|
-
return sendJson(res, 200, apiAuth());
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
if (method === 'POST' && Object.prototype.hasOwnProperty.call(POST_HANDLERS, pathName)) {
|
|
1506
|
-
let body;
|
|
1507
|
-
try {
|
|
1508
|
-
body = await readJsonBody(req);
|
|
1509
|
-
} catch (err) {
|
|
1510
|
-
return sendJson(res, 400, { ok: false, error: (err && err.message) || 'bad request body' });
|
|
1511
|
-
}
|
|
1512
|
-
// Handlers may be sync OR async (apiMcpDiscover connects an upstream) - await covers both.
|
|
1513
|
-
const result = await POST_HANDLERS[pathName](body);
|
|
1514
|
-
// Audit a config-MUTATING write that SUCCEEDED (self-gating - no-op unless logging is on).
|
|
1515
|
-
if (result && result.status >= 200 && result.status < 300 && result.json && result.json.ok === true && CONFIG_EVENTS[pathName]) {
|
|
1516
|
-
logConfigChange(CONFIG_EVENTS[pathName](body || {}));
|
|
1517
|
-
}
|
|
1518
|
-
return sendJson(res, result.status, result.json);
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
return sendJson(res, 404, { ok: false, error: 'not found: ' + method + ' ' + pathName });
|
|
1522
|
-
} catch (err) {
|
|
1523
|
-
// Last-resort guard: any unexpected failure becomes a clean 500 JSON, never a crash.
|
|
1524
|
-
logErr('onRequest error:', (err && err.stack) || String(err));
|
|
1525
|
-
try {
|
|
1526
|
-
sendJson(res, 500, { ok: false, error: (err && err.message) || 'internal error' });
|
|
1527
|
-
} catch (_e) {
|
|
1528
|
-
/* response may already be committed */
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
// ── Lifecycle ───────────────────────────────────────────────────────────────────────────────
|
|
1534
|
-
|
|
1535
|
-
/**
|
|
1536
|
-
* start - bind the HTTP server. On EADDRINUSE the returned promise REJECTS with a clear error
|
|
1537
|
-
* (the caller decides) - it does NOT crash the process. Resolves to { url, port } once listening.
|
|
1538
|
-
* Port 0 -> an OS-assigned ephemeral port (read back from server.address()).
|
|
1539
|
-
* @returns {Promise<{url:string, port:number}>}
|
|
1540
|
-
*/
|
|
1541
|
-
function start() {
|
|
1542
|
-
if (started) return Promise.resolve({ url: currentUrl(), port: boundPort });
|
|
1543
|
-
// The HTTP transport refuses a non-loopback bind unless OAuth is enabled; the UI has NO auth
|
|
1544
|
-
// path, so its refusal is unconditional. This endpoint spawns processes and writes scripts -
|
|
1545
|
-
// "--ui --host 0.0.0.0" would hand an unauthenticated remote console to the LAN.
|
|
1546
|
-
if (!isLoopbackBindHost(host)) {
|
|
1547
|
-
return Promise.reject(new Error(
|
|
1548
|
-
`refusing to bind the config UI to non-loopback host "${host}" - the UI is unauthenticated ` +
|
|
1549
|
-
'and can spawn processes / write tool scripts. It is loopback-only by design; to configure ' +
|
|
1550
|
-
'the gateway from another machine, use an SSH tunnel to 127.0.0.1 instead.'
|
|
1551
|
-
));
|
|
1552
|
-
}
|
|
1553
|
-
httpServer = http.createServer(onRequest);
|
|
1554
|
-
return new Promise((resolve, reject) => {
|
|
1555
|
-
const onListenError = (err) => {
|
|
1556
|
-
httpServer.removeListener('listening', onListening);
|
|
1557
|
-
httpServer = null;
|
|
1558
|
-
reject(err);
|
|
1559
|
-
};
|
|
1560
|
-
const onListening = () => {
|
|
1561
|
-
httpServer.removeListener('error', onListenError);
|
|
1562
|
-
const addr = httpServer.address();
|
|
1563
|
-
boundPort = addr && typeof addr === 'object' ? addr.port : requestedPort;
|
|
1564
|
-
started = true;
|
|
1565
|
-
// Swap the one-shot bind-error handler for a steady-state one so a later socket error
|
|
1566
|
-
// (a client RST, etc.) is logged, not thrown.
|
|
1567
|
-
httpServer.on('error', (e) => logErr('http server error:', (e && e.message) || String(e)));
|
|
1568
|
-
resolve({ url: currentUrl(), port: boundPort });
|
|
1569
|
-
};
|
|
1570
|
-
httpServer.once('error', onListenError);
|
|
1571
|
-
httpServer.once('listening', onListening);
|
|
1572
|
-
httpServer.listen(requestedPort, host);
|
|
1573
|
-
});
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
/** stop - close the HTTP server. Idempotent and NEVER throws. */
|
|
1577
|
-
function stop() {
|
|
1578
|
-
return new Promise((resolve) => {
|
|
1579
|
-
if (!httpServer) {
|
|
1580
|
-
started = false;
|
|
1581
|
-
boundPort = null;
|
|
1582
|
-
return resolve();
|
|
1583
|
-
}
|
|
1584
|
-
const srv = httpServer;
|
|
1585
|
-
httpServer = null;
|
|
1586
|
-
let settled = false;
|
|
1587
|
-
const done = () => { if (!settled) { settled = true; started = false; boundPort = null; resolve(); } };
|
|
1588
|
-
try {
|
|
1589
|
-
srv.close(done);
|
|
1590
|
-
} catch (_e) {
|
|
1591
|
-
done();
|
|
1592
|
-
}
|
|
1593
|
-
try {
|
|
1594
|
-
if (typeof srv.closeAllConnections === 'function') srv.closeAllConnections();
|
|
1595
|
-
} catch (_e) {
|
|
1596
|
-
/* closeAllConnections is Node ≥18.2; absent -> close() still resolves */
|
|
1597
|
-
}
|
|
1598
|
-
});
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
return {
|
|
1602
|
-
start,
|
|
1603
|
-
stop,
|
|
1604
|
-
get url() {
|
|
1605
|
-
return currentUrl();
|
|
1606
|
-
},
|
|
1607
|
-
};
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
module.exports = {
|
|
1611
|
-
createUiServer,
|
|
1612
|
-
// Exported for unit tests / reuse - the small pure seams.
|
|
1613
|
-
isLoopbackHost,
|
|
1614
|
-
isLoopbackBindHost,
|
|
1615
|
-
escapeRegex,
|
|
1616
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* server.js - the OPTIONAL config web UI for the gateway (loopback-only).
|
|
5
|
+
*
|
|
6
|
+
* A zero-dependency node:http server that lets a human VIEW / SEARCH / CONFIGURE
|
|
7
|
+
* the gateway's tools, upstream MCPs, and hooks without hand-editing JSON. It does
|
|
8
|
+
* NOT reinvent any state: every read and write goes THROUGH the gateway's proven stores -
|
|
9
|
+
* - the tool register (src/tools/registry.js :: loadRegistry -> add/update/remove/writeScript)
|
|
10
|
+
* - the enabled overlay (src/tools/tool-state.js :: loadToolState / setToolEnabled / clearToolState)
|
|
11
|
+
* - the MCP expose store (src/mcp/expose-store.js :: loadExposeStore -> addUpstream/addExpose/...)
|
|
12
|
+
* - the hook loader (src/core/hook-loader.js :: loadManifest -> addEntry/setEnabled/removeEntry)
|
|
13
|
+
* - the hook matcher (src/core/matcher.js :: matches)
|
|
14
|
+
* - the atomic writer (src/tools/registry.js :: atomicWriteJson) [tool Pre/Post gate writes]
|
|
15
|
+
* so a UI edit is byte-identical to a CLI edit and is visible to the running MCP
|
|
16
|
+
* server with no restart (the register/state/manifest are all re-read per call).
|
|
17
|
+
*
|
|
18
|
+
* The UI runs in-process for the trusted human operator, so it uses the stores
|
|
19
|
+
* DIRECTLY (no MCP round-trip). Every write is atomic (temp + rename) inside the store.
|
|
20
|
+
*
|
|
21
|
+
* SAFETY CONTRACT (mirrors src/mcp/http-transport.js):
|
|
22
|
+
* - start() HARD-REFUSES a non-loopback bind host. The UI has no auth path at all, and it can
|
|
23
|
+
* spawn processes (mcp add/discover) and write scripts (tools/hooks) - off-loopback it would be
|
|
24
|
+
* an unauthenticated remote console. Loopback-only by design; no flag overrides it.
|
|
25
|
+
* - Binds 127.0.0.1 by default; a non-loopback Host header is rejected (DNS-rebind guard).
|
|
26
|
+
* - The request listener NEVER throws - a bad request becomes a clean JSON error.
|
|
27
|
+
* - start() rejects cleanly on EADDRINUSE (caller decides); stop() is idempotent.
|
|
28
|
+
* - Every store read is FRESH per request, so edits made elsewhere show immediately.
|
|
29
|
+
*
|
|
30
|
+
* CommonJS only. Node BUILT-INS only (node:http, node:fs, node:path) - no new npm dep,
|
|
31
|
+
* no CDN, no framework. The static assets under src/ui/public are vanilla HTML/CSS/JS.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const http = require('node:http');
|
|
35
|
+
const fs = require('node:fs');
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
|
|
38
|
+
const { loadRegistry, atomicWriteJson, resolveMode } = require('../tools/registry');
|
|
39
|
+
const { loadToolState, isToolEnabled, isToolHidden, isToolHot, setToolEnabled, setToolHidden, setToolHot, clearToolState, getPassthrough, setPassthrough } = require('../tools/tool-state');
|
|
40
|
+
const { loadExposeStore } = require('../mcp/expose-store');
|
|
41
|
+
const { Aggregator } = require('../mcp/aggregator');
|
|
42
|
+
const { loadManifest, normalizeState } = require('../core/hook-loader');
|
|
43
|
+
const { matches } = require('../core/matcher');
|
|
44
|
+
const { META_TOOLS } = require('../mcp/protocol');
|
|
45
|
+
const logger = require('../core/logger');
|
|
46
|
+
const { loadServerConfig } = require('../core/server-config');
|
|
47
|
+
const authConfig = require('../auth/config');
|
|
48
|
+
const { isJoseInstalled, JOSE_PIN } = require('../auth/resource-server');
|
|
49
|
+
const { installJose } = require('../auth/install');
|
|
50
|
+
|
|
51
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
52
|
+
const DEFAULT_PORT = 9777;
|
|
53
|
+
// Config POST bodies are small, but an "add" may carry a scriptText file body; 256 KiB is
|
|
54
|
+
// generous headroom for a tool/hook script while still capping an abusive request.
|
|
55
|
+
const MAX_BODY_BYTES = 256 * 1024;
|
|
56
|
+
const VALID_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
|
|
57
|
+
|
|
58
|
+
// Static asset content types (vanilla, offline - no external fonts/CDN).
|
|
59
|
+
const CONTENT_TYPES = Object.freeze({
|
|
60
|
+
'.html': 'text/html; charset=utf-8',
|
|
61
|
+
'.css': 'text/css; charset=utf-8',
|
|
62
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
63
|
+
'.json': 'application/json; charset=utf-8',
|
|
64
|
+
'.jpg': 'image/jpeg',
|
|
65
|
+
'.jpeg': 'image/jpeg',
|
|
66
|
+
'.png': 'image/png',
|
|
67
|
+
'.svg': 'image/svg+xml',
|
|
68
|
+
'.ico': 'image/x-icon',
|
|
69
|
+
'.webp': 'image/webp',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Is the Host header loopback? We bind 127.0.0.1 only, but a forwarded/rebound
|
|
74
|
+
* request could still arrive with a non-loopback Host. Defence-in-depth: reject
|
|
75
|
+
* anything whose host part is not a recognised loopback name (mirrors
|
|
76
|
+
* http-transport.js::isLoopbackHost). A MISSING Host header is allowed - the bind
|
|
77
|
+
* address is already the hard boundary.
|
|
78
|
+
* @param {string|undefined} hostHeader
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
function isLoopbackHost(hostHeader) {
|
|
82
|
+
if (hostHeader == null || hostHeader === '') return true;
|
|
83
|
+
let host = String(hostHeader).trim();
|
|
84
|
+
if (host[0] === '[') {
|
|
85
|
+
const end = host.indexOf(']');
|
|
86
|
+
host = end === -1 ? host.slice(1) : host.slice(1, end);
|
|
87
|
+
} else {
|
|
88
|
+
// Strip an optional :port ONLY when unambiguous - exactly one colon means "host:port". A string
|
|
89
|
+
// with MULTIPLE colons is a bare IPv6 literal (e.g. "::1"); compare it whole rather than slicing
|
|
90
|
+
// it to "" at the first colon (which would mis-classify IPv6 loopback as non-loopback).
|
|
91
|
+
const first = host.indexOf(':');
|
|
92
|
+
if (first !== -1 && first === host.lastIndexOf(':')) host = host.slice(0, first);
|
|
93
|
+
}
|
|
94
|
+
host = host.toLowerCase();
|
|
95
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Is the BIND address loopback? STRICTER than the Host-header check above: a missing Host header
|
|
100
|
+
* is legitimately loopback (the bind address is the boundary), but a missing/empty BIND address is
|
|
101
|
+
* not a loopback claim - it must name a loopback host explicitly to pass.
|
|
102
|
+
* @param {string|undefined} bindHost
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
function isLoopbackBindHost(bindHost) {
|
|
106
|
+
return typeof bindHost === 'string' && bindHost.trim() !== '' && isLoopbackHost(bindHost);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Regex-escape a literal so a matcher built from it FULL-matches the string verbatim. */
|
|
110
|
+
function escapeRegex(str) {
|
|
111
|
+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The SURFACED name a discovered upstream tool is exposed under - an ENABLED expose `as` for
|
|
116
|
+
* (upstream, tool) if one exists, else the namespaced default `<upstream>_<tool>`. Mirrors
|
|
117
|
+
* Aggregator._surfacedName so the UI's per-tool curation keys match what the running gateway reads.
|
|
118
|
+
* @param {object} store the ExposeStore (for exposedName)
|
|
119
|
+
* @param {Array} enabledExposed store.listExposed({ upstream, enabledOnly:true })
|
|
120
|
+
* @param {string} upstreamId
|
|
121
|
+
* @param {string} toolName
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
function surfacedNameFor(store, enabledExposed, upstreamId, toolName) {
|
|
125
|
+
for (const e of enabledExposed || []) {
|
|
126
|
+
if (e && e.tool === toolName) return store.exposedName(e);
|
|
127
|
+
}
|
|
128
|
+
return `${upstreamId}_${toolName}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Race a promise against a timeout so a wedged upstream never hangs a UI request. Rejects with
|
|
132
|
+
* `msg` on timeout; the timer is unref'd so it never keeps the process alive. */
|
|
133
|
+
function withTimeout(promise, ms, msg) {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const timer = setTimeout(() => reject(new Error(msg || 'timed out')), ms);
|
|
136
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
137
|
+
Promise.resolve(promise).then(
|
|
138
|
+
(v) => { clearTimeout(timer); resolve(v); },
|
|
139
|
+
(e) => { clearTimeout(timer); reject(e); }
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Diagnostics -> stderr (never the response). NEVER throws. */
|
|
145
|
+
function logErr(...parts) {
|
|
146
|
+
try {
|
|
147
|
+
process.stderr.write('[toolfunnel-ui] ' + parts.join(' ') + '\n');
|
|
148
|
+
} catch (_e) {
|
|
149
|
+
/* never let logging throw */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Config-change audit (UI -> activity log) ─────────────────────────────────────────────────────
|
|
154
|
+
// A management console mutates the gateway's security posture (which tools are visible/hot, which
|
|
155
|
+
// MCPs are attached, which hooks gate, whether OAuth is on). Auditing those changes is arguably more
|
|
156
|
+
// important than logging individual tool runs. This maps each config-MUTATING POST path to an audit
|
|
157
|
+
// record (body -> fields). Read-only / test endpoints (e.g. /api/mcp/discover) are deliberately ABSENT
|
|
158
|
+
// so they never log as a "change". Logging self-gates (logger.log is a no-op unless logging is on),
|
|
159
|
+
// so this is silent until the operator enables the activity log.
|
|
160
|
+
const CONFIG_EVENTS = {
|
|
161
|
+
'/api/tools/state': (b) => ({ event: 'tool_state', id: b.id, enabled: b.enabled, hidden: b.hidden, hot: b.hot }),
|
|
162
|
+
'/api/tools/hook': (b) => ({ event: 'tool_hook', id: b.id, hook: b.event, on: !!b.on }),
|
|
163
|
+
'/api/tools/add': (b) => ({ event: 'tool_add', id: b.entry && b.entry.id }),
|
|
164
|
+
'/api/tools/remove': (b) => ({ event: 'tool_remove', id: b.id }),
|
|
165
|
+
'/api/tools/mode': (b) => ({ event: 'tool_mode', id: b.id, mode: b.mode }),
|
|
166
|
+
'/api/tools/update': (b) => ({ event: 'tool_update', id: b.id }),
|
|
167
|
+
'/api/hooks/add': (b) => ({ event: 'hook_add', id: b.entry && b.entry.id }),
|
|
168
|
+
'/api/hooks/state': (b) => ({ event: 'hook_state', id: b.id, action: b.action }),
|
|
169
|
+
'/api/mcp/add': (b) => ({ event: 'mcp_add', id: b.upstream && b.upstream.id }),
|
|
170
|
+
'/api/mcp/state': (b) => ({ event: 'mcp_state', id: b.id, action: b.action }),
|
|
171
|
+
'/api/wrap': (b) => ({ event: 'wrap', upstream: (b && b.upstream) || null }),
|
|
172
|
+
'/api/identity': () => ({ event: 'identity_config' }),
|
|
173
|
+
'/api/auth/config': (b) => ({ event: 'auth_config', enabled: b.enabled, issuer: b.issuer }),
|
|
174
|
+
'/api/logs/config': (b) => ({ event: 'log_config', enabled: b.enabled }),
|
|
175
|
+
'/api/oauth/install': () => ({ event: 'oauth_install' }),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Write a config-change audit record (type:'config', via:'ui'). Drops undefined fields so a partial
|
|
180
|
+
* change (e.g. only `hot` toggled) records only what actually changed. Self-gating via logger.log
|
|
181
|
+
* (no-op unless logging is enabled). NEVER throws - an audit failure must not break a config write.
|
|
182
|
+
* @param {object} fields { event, ...changed }
|
|
183
|
+
*/
|
|
184
|
+
function logConfigChange(fields) {
|
|
185
|
+
try {
|
|
186
|
+
const rec = { type: 'config', via: 'ui' };
|
|
187
|
+
const f = fields || {};
|
|
188
|
+
for (const k of Object.keys(f)) {
|
|
189
|
+
if (f[k] !== undefined) rec[k] = f[k];
|
|
190
|
+
}
|
|
191
|
+
logger.log(rec);
|
|
192
|
+
} catch (_e) {
|
|
193
|
+
/* never let audit logging break the caller */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* createUiServer - construct (but do NOT start) the config web UI server.
|
|
199
|
+
*
|
|
200
|
+
* @param {object} [opts]
|
|
201
|
+
* @param {string} [opts.host] bind address (default 127.0.0.1 - loopback only).
|
|
202
|
+
* @param {number} [opts.port] bind port (default 9777; pass 0 for an OS-assigned port).
|
|
203
|
+
* @param {string} [opts.root] config root holding tools/ mcp/ hooks/ (default: the resolved
|
|
204
|
+
* config home - TOOLFUNNEL_HOME / --config-dir / the package root).
|
|
205
|
+
* @returns {{ start: () => Promise<{url:string, port:number}>, stop: () => Promise<void>, get url():(string|null) }}
|
|
206
|
+
*/
|
|
207
|
+
function createUiServer(opts = {}) {
|
|
208
|
+
const o = opts || {};
|
|
209
|
+
const host = typeof o.host === 'string' && o.host.length > 0 ? o.host : DEFAULT_HOST;
|
|
210
|
+
// A port of 0 is VALID (OS-assigned) so distinguish "not provided" from 0.
|
|
211
|
+
const requestedPort = Number.isInteger(o.port) && o.port >= 0 ? o.port : DEFAULT_PORT;
|
|
212
|
+
// The config root: tools.register.json etc. live under <root>/tools, <root>/mcp, <root>/hooks.
|
|
213
|
+
// Default = the resolved config home (see src/core/config-home.js), so a --config-dir /
|
|
214
|
+
// TOOLFUNNEL_HOME gateway and its UI edit the SAME stores.
|
|
215
|
+
const { resolveConfigHome } = require('../core/config-home');
|
|
216
|
+
const root = typeof o.root === 'string' && o.root.length > 0 ? o.root : resolveConfigHome();
|
|
217
|
+
|
|
218
|
+
// ── Path anchors (everything resolved from the repo root) ────────────────────────────────────
|
|
219
|
+
const REGISTER_PATH = path.join(root, 'tools', 'tools.register.json');
|
|
220
|
+
const TOOL_STATE_PATH = path.join(root, 'tools', 'tools.state.json');
|
|
221
|
+
const EXPOSE_PATH = path.join(root, 'mcp', 'expose.json');
|
|
222
|
+
const MANIFEST_PATH = path.join(root, 'hooks', 'hooks.manifest.json');
|
|
223
|
+
const HOOK_STATE_PATH = path.join(root, 'hooks', 'hooks.state.json');
|
|
224
|
+
const SCRIPTS_ROOT = path.join(root, 'tools', 'scripts');
|
|
225
|
+
// Where a Pre/Post hook script for a tool must be authored. The manifest command carries the
|
|
226
|
+
// PORTABLE ${HOOKS_DIR} token (expanded by the hook-loader at load); this is its real on-disk dir.
|
|
227
|
+
const HOOK_SCRIPTS_DIR = path.join(root, 'hooks', 'scripts');
|
|
228
|
+
const TOOLFUNNEL_JSON = path.join(root, 'toolfunnel.json');
|
|
229
|
+
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
230
|
+
|
|
231
|
+
// ── Mutable runtime state ─────────────────────────────────────────────────────────────────────
|
|
232
|
+
/** @type {import('node:http').Server|null} */
|
|
233
|
+
let httpServer = null;
|
|
234
|
+
/** @type {number|null} the actual bound port (resolved after listen - matters for port 0). */
|
|
235
|
+
let boundPort = null;
|
|
236
|
+
let started = false;
|
|
237
|
+
|
|
238
|
+
function currentUrl() {
|
|
239
|
+
return boundPort == null ? null : `http://${host}:${boundPort}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Store-backed config reads (FRESH per call so external edits show immediately) ──────────────
|
|
243
|
+
|
|
244
|
+
/** Read the hook manifest defensively -> { version, hooks:[] }. Never throws. */
|
|
245
|
+
function readManifest() {
|
|
246
|
+
try {
|
|
247
|
+
const d = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
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.
|
|
252
|
+
return {
|
|
253
|
+
...d,
|
|
254
|
+
version: typeof d.version === 'number' ? d.version : 1,
|
|
255
|
+
hooks: Array.isArray(d.hooks) ? d.hooks : [],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
} catch (_e) {
|
|
259
|
+
/* missing/malformed -> empty manifest */
|
|
260
|
+
}
|
|
261
|
+
return { version: 1, hooks: [] };
|
|
262
|
+
}
|
|
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
|
+
|
|
296
|
+
/** Does any ENABLED manifest hook for `event` fire for `gateName`? (matcher.matches semantics) */
|
|
297
|
+
function hookFires(hooks, event, gateName) {
|
|
298
|
+
for (const h of hooks) {
|
|
299
|
+
if (!h || h.event !== event || h.enabled !== true) continue;
|
|
300
|
+
if (matches(h.matcher, gateName)) return true;
|
|
301
|
+
}
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The gate name the hook matcher is tested against: register `name`, falling back to `id`. */
|
|
306
|
+
function gateNameFor(entry) {
|
|
307
|
+
return (entry && entry.name) || (entry && entry.id) || '';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// GET /api/tools -> [{ id,name,summary,category, mode, enabled, hidden, hot, pre, post }]
|
|
311
|
+
function apiTools() {
|
|
312
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
313
|
+
const state = loadToolState(TOOL_STATE_PATH);
|
|
314
|
+
const hooks = effectiveHooks();
|
|
315
|
+
return registry.list().map((b) => {
|
|
316
|
+
const gateName = b.name || b.id;
|
|
317
|
+
return {
|
|
318
|
+
id: b.id,
|
|
319
|
+
name: b.name,
|
|
320
|
+
summary: b.summary || '',
|
|
321
|
+
category: b.category || '',
|
|
322
|
+
// The RESOLVED execution mode ("gateway" runs the invoke here; "reference"
|
|
323
|
+
// hands the instructions back so the connected AI runs it). b.id is from the
|
|
324
|
+
// register so registry.mode never throws here.
|
|
325
|
+
mode: registry.mode(b.id),
|
|
326
|
+
// The visibility MATRIX axes (all keyed by the tool id):
|
|
327
|
+
// enabled - LEAN-VISIBLE in toolfunnel_list_tools + runnable (default ON).
|
|
328
|
+
// hidden - manager-list declutter only (default OFF).
|
|
329
|
+
// hot - promoted to the TOP-LEVEL every-turn surface (default OFF; opt-in).
|
|
330
|
+
enabled: isToolEnabled(state, b.id),
|
|
331
|
+
hidden: isToolHidden(state, b.id),
|
|
332
|
+
hot: isToolHot(state, b.id, false),
|
|
333
|
+
pre: hookFires(hooks, 'PreToolUse', gateName),
|
|
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"'),
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// POST /api/tools/state {id, enabled?, hidden?, hot?} -> persist whichever axes are present. Each
|
|
352
|
+
// is an independent merge-write (preserves the others). Backward-compatible: {id, enabled} still
|
|
353
|
+
// just flips enabled. `id` may be a local tool id, an upstream surfaced name, or a meta-tool name
|
|
354
|
+
// (the `hot` axis is keyed by the surfaced name the matrix assembler reads).
|
|
355
|
+
function apiSetState(body) {
|
|
356
|
+
const id = body && body.id;
|
|
357
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
358
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
359
|
+
}
|
|
360
|
+
let touched = false;
|
|
361
|
+
// Wrap the atomic writes (they can throw on EACCES/EPERM/ENOSPC) so the handler honours the
|
|
362
|
+
// POST contract "never throws" and returns a clean {ok:false} with a useful message, like the
|
|
363
|
+
// other write handlers - rather than escaping to the generic outer 500.
|
|
364
|
+
try {
|
|
365
|
+
if (Object.prototype.hasOwnProperty.call(body, 'enabled')) { setToolEnabled(TOOL_STATE_PATH, id, !!body.enabled); touched = true; }
|
|
366
|
+
if (Object.prototype.hasOwnProperty.call(body, 'hidden')) { setToolHidden(TOOL_STATE_PATH, id, !!body.hidden); touched = true; }
|
|
367
|
+
if (Object.prototype.hasOwnProperty.call(body, 'hot')) { setToolHot(TOOL_STATE_PATH, id, !!body.hot); touched = true; }
|
|
368
|
+
} catch (err) {
|
|
369
|
+
return { status: 500, json: { ok: false, error: (err && err.message) || 'tool state write failed' } };
|
|
370
|
+
}
|
|
371
|
+
if (!touched) {
|
|
372
|
+
return { status: 400, json: { ok: false, error: 'provide at least one of enabled, hidden, hot' } };
|
|
373
|
+
}
|
|
374
|
+
return { status: 200, json: { ok: true } };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// GET /api/surface -> the TOP-LEVEL (every-turn) surface summary + footgun warnings.
|
|
378
|
+
// meta - the 4 meta-tools with their hot state (default ON; hot:false hides one).
|
|
379
|
+
// promotedLocal - register tools promoted hot AND enabled.
|
|
380
|
+
// promotedOther - other hot+enabled state keys (upstream surfaced names) - a count proxy (the
|
|
381
|
+
// UI server can't enumerate live upstream tools without a discover, so this may
|
|
382
|
+
// over-count typo'd / disabled-upstream keys; it errs toward over-warning).
|
|
383
|
+
// curatedDirect - ENABLED expose[] entries: these are on the top-level surface every turn too
|
|
384
|
+
// (handleToolsList step 3 promotes every enabled expose entry, with NO hot flag),
|
|
385
|
+
// so the bloat warning MUST count them or it reports a false all-clear.
|
|
386
|
+
// warnings - hiding the management tools / promoting many (context bloat).
|
|
387
|
+
function apiSurface() {
|
|
388
|
+
const state = loadToolState(TOOL_STATE_PATH);
|
|
389
|
+
const metaNames = Object.values(META_TOOLS);
|
|
390
|
+
const meta = metaNames.map((name) => ({ name, hot: isToolHot(state, name, true) }));
|
|
391
|
+
|
|
392
|
+
let localIds = new Set();
|
|
393
|
+
try { localIds = new Set(loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT }).list().map((b) => b.id)); }
|
|
394
|
+
catch (_e) { /* report 0 promotions on a malformed register */ }
|
|
395
|
+
|
|
396
|
+
// Enabled expose[] entries are curated-direct: top-level every turn (no hot flag needed), so they
|
|
397
|
+
// count toward the bloat threshold exactly like a hot tool - statically enumerable, no discover.
|
|
398
|
+
// Collect their SURFACED names so a hot key that is ALSO an enabled-expose name isn't double-counted.
|
|
399
|
+
let curatedDirect = 0;
|
|
400
|
+
const exposedNames = new Set();
|
|
401
|
+
try {
|
|
402
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
403
|
+
const exposed = store.listExposed({ enabledOnly: true });
|
|
404
|
+
curatedDirect = exposed.length;
|
|
405
|
+
for (const e of exposed) exposedNames.add(store.exposedName(e));
|
|
406
|
+
} catch (_e) { /* 0 on a malformed store */ }
|
|
407
|
+
|
|
408
|
+
let promotedLocal = 0;
|
|
409
|
+
let promotedOther = 0;
|
|
410
|
+
for (const key of Object.keys(state)) {
|
|
411
|
+
const e = state[key];
|
|
412
|
+
if (!e || e.hot !== true) continue; // only explicit promotions
|
|
413
|
+
if (metaNames.includes(key)) continue; // meta handled separately
|
|
414
|
+
if (e.enabled === false) continue; // disabled -> not actually on the surface
|
|
415
|
+
if (localIds.has(key)) promotedLocal += 1;
|
|
416
|
+
else if (exposedNames.has(key)) continue; // already counted as curated-direct (no double-count)
|
|
417
|
+
else promotedOther += 1;
|
|
418
|
+
}
|
|
419
|
+
const promotedTotal = promotedLocal + promotedOther + curatedDirect;
|
|
420
|
+
|
|
421
|
+
const warnings = [];
|
|
422
|
+
const listHot = (meta.find((m) => m.name === META_TOOLS.LIST) || {}).hot;
|
|
423
|
+
const runHot = (meta.find((m) => m.name === META_TOOLS.RUN) || {}).hot;
|
|
424
|
+
const metaOff = meta.filter((m) => !m.hot).length;
|
|
425
|
+
if (metaOff === metaNames.length) {
|
|
426
|
+
warnings.push('All four management tools are hidden from the top-level surface. A connected AI can no longer discover or run tools via the meta-tools - only the tools you have promoted are reachable. This is the "ordinary tools as an MCP" pattern; otherwise re-enable at least toolfunnel_list_tools and toolfunnel_run_tool.');
|
|
427
|
+
} else if (!listHot || !runHot) {
|
|
428
|
+
warnings.push('toolfunnel_list_tools and/or toolfunnel_run_tool is hidden - the AI may be unable to discover or run tools by name. Promote the specific tools it needs, or re-enable these meta-tools.');
|
|
429
|
+
}
|
|
430
|
+
if (promotedTotal > 10) {
|
|
431
|
+
warnings.push(promotedTotal + ' tools are promoted to every turn (including any enabled curated-direct expose entries). Each injects its schema into the model context on EVERY message - the opposite of the lean register. Promote only the few you call constantly; surface the rest leanly via toolfunnel_list_tools.');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return { meta, promotedLocal, promotedOther, curatedDirect, promotedTotal, warnings };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// POST /api/tools/hook {id, event, on} -> read-modify-write hooks.manifest.json atomically.
|
|
438
|
+
function apiSetHook(body) {
|
|
439
|
+
const id = body && body.id;
|
|
440
|
+
const event = body && body.event;
|
|
441
|
+
const on = !!(body && body.on);
|
|
442
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
443
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
444
|
+
}
|
|
445
|
+
if (!VALID_HOOK_EVENTS.has(event)) {
|
|
446
|
+
return { status: 400, json: { ok: false, error: 'event must be "PreToolUse" or "PostToolUse"' } };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Resolve the tool's GATE NAME (register name, falling back to id). Reuse the registry so an
|
|
450
|
+
// unknown id is rejected cleanly rather than fabricating a manifest entry for nothing.
|
|
451
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
452
|
+
if (!registry.has(id)) {
|
|
453
|
+
return { status: 404, json: { ok: false, error: `unknown tool id "${id}"` } };
|
|
454
|
+
}
|
|
455
|
+
const gateName = gateNameFor(registry.getEntry(id));
|
|
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
|
+
|
|
473
|
+
const manifest = readManifest();
|
|
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
|
+
};
|
|
492
|
+
const eventLower = event.toLowerCase();
|
|
493
|
+
const scriptName = `${id}-${eventLower}.js`;
|
|
494
|
+
const scriptPath = path.join(HOOK_SCRIPTS_DIR, scriptName);
|
|
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
|
+
|
|
517
|
+
if (on) {
|
|
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
|
+
}
|
|
601
|
+
hooks.push({
|
|
602
|
+
event,
|
|
603
|
+
matcher: literal,
|
|
604
|
+
// The command carries the PORTABLE ${HOOKS_DIR} token (NOT interpolated here) - the
|
|
605
|
+
// hook-loader expands it to the absolute hooks dir at load time.
|
|
606
|
+
command: ourCommand,
|
|
607
|
+
enabled: true,
|
|
608
|
+
});
|
|
609
|
+
manifest.hooks = hooks;
|
|
610
|
+
atomicWriteJson(MANIFEST_PATH, manifest);
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
status: 200,
|
|
614
|
+
json: {
|
|
615
|
+
ok: true,
|
|
616
|
+
scriptPath,
|
|
617
|
+
note:
|
|
618
|
+
`Create ${scriptName} at this path to define the ${event} behaviour. ` +
|
|
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).',
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
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) {
|
|
631
|
+
manifest.hooks = next;
|
|
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);
|
|
637
|
+
}
|
|
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 };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// GET /api/upstreams -> { upstreams:[...], expose:[...] } (READ-ONLY for v1).
|
|
655
|
+
function apiUpstreams() {
|
|
656
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
657
|
+
return {
|
|
658
|
+
upstreams: store.listUpstreams(),
|
|
659
|
+
expose: store.listExposed(),
|
|
660
|
+
// The active passthrough wrap (0.6.0) - null when the normal funnel surface is live.
|
|
661
|
+
wrapping: getPassthrough(loadToolState(TOOL_STATE_PATH)),
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* POST /api/wrap - set or clear the PASSTHROUGH wrap. Body { upstream:'<id>' } wraps (that
|
|
667
|
+
* upstream's tools become the ENTIRE surface, meta-tools hidden, every call still gated);
|
|
668
|
+
* { upstream:null } clears. The UI is one of the two recovery paths from an AI-set wrap
|
|
669
|
+
* (the other is `toolfunnel wrap --off`), so this endpoint must never itself be funnelled.
|
|
670
|
+
*/
|
|
671
|
+
function apiWrap(body) {
|
|
672
|
+
// Sibling handlers wrap their store writes so the "handler never throws, returns clean
|
|
673
|
+
// {ok:false,error}" contract holds even on EACCES/ENOSPC (mirrors apiSetState).
|
|
674
|
+
try {
|
|
675
|
+
const b = body || {};
|
|
676
|
+
const id = b.upstream;
|
|
677
|
+
if (id === null || id === undefined || id === '') {
|
|
678
|
+
const was = getPassthrough(loadToolState(TOOL_STATE_PATH));
|
|
679
|
+
setPassthrough(TOOL_STATE_PATH, null);
|
|
680
|
+
return { status: 200, json: { ok: true, wrapping: null, was } };
|
|
681
|
+
}
|
|
682
|
+
if (typeof id !== 'string') {
|
|
683
|
+
return { status: 400, json: { ok: false, error: 'upstream must be an attached upstream id, or null to clear' } };
|
|
684
|
+
}
|
|
685
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
686
|
+
const upstream = store.getUpstream(id);
|
|
687
|
+
if (!upstream) {
|
|
688
|
+
return { status: 404, json: { ok: false, error: 'no upstream with id "' + id + '"' } };
|
|
689
|
+
}
|
|
690
|
+
if (upstream.enabled === false) {
|
|
691
|
+
return { status: 400, json: { ok: false, error: 'upstream "' + id + '" is disabled - enable it first' } };
|
|
692
|
+
}
|
|
693
|
+
setPassthrough(TOOL_STATE_PATH, id);
|
|
694
|
+
// Wrap security notice - same check the CLI prints: wrapping SUSPENDS the path-isolation
|
|
695
|
+
// guard for this upstream, so outside paths are permitted by design. The UI operator gets
|
|
696
|
+
// the same informed consent the CLI operator gets.
|
|
697
|
+
let warning = null;
|
|
698
|
+
try {
|
|
699
|
+
const { looksLikePath, isInside } = require('../mcp/aggregator');
|
|
700
|
+
const outsideArgs = (Array.isArray(upstream.args) ? upstream.args : [])
|
|
701
|
+
.filter((a) => looksLikePath(a) && !isInside(root, a));
|
|
702
|
+
if (outsideArgs.length) {
|
|
703
|
+
warning = 'WRAP SECURITY NOTICE: this upstream uses paths outside the gateway root (' +
|
|
704
|
+
outsideArgs.join(', ') + '). While wrapped, the path-isolation guard is suspended for it - ' +
|
|
705
|
+
'the wrapped server can reach whatever those paths reach. Every call still passes the ' +
|
|
706
|
+
'PreToolUse gate; to restrict it, add a PreToolUse hook or disable individual tools. ' +
|
|
707
|
+
'See the manual, "Wrapping & security".';
|
|
708
|
+
}
|
|
709
|
+
} catch (_e) { /* the notice is best-effort - never block the wrap on it */ }
|
|
710
|
+
return { status: 200, json: { ok: true, wrapping: id, ...(warning ? { warning } : {}) } };
|
|
711
|
+
} catch (err) {
|
|
712
|
+
return { status: 500, json: { ok: false, error: (err && err.message) || 'wrap update failed' } };
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ── Identity & ports (toolfunnel.json - 0.6.0 identity settings) ─────────────────────────────
|
|
717
|
+
|
|
718
|
+
// GET /api/identity -> { file, effective, restartRequired:true }. `file` is the raw
|
|
719
|
+
// toolfunnel.json content (absent/bad JSON -> {}), `effective` is what the gateway resolves
|
|
720
|
+
// from it (flag > config > default - same loader the gateway boots with).
|
|
721
|
+
function apiIdentity() {
|
|
722
|
+
let file = {};
|
|
723
|
+
try {
|
|
724
|
+
const parsed = JSON.parse(fs.readFileSync(TOOLFUNNEL_JSON, 'utf8'));
|
|
725
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) file = parsed;
|
|
726
|
+
} catch (_e) { /* absent or bad JSON - the loader treats it as defaults too */ }
|
|
727
|
+
let effective = null;
|
|
728
|
+
try { effective = loadServerConfig(root); } catch (_e) { /* tolerant loader; belt-and-braces */ }
|
|
729
|
+
return { file, effective, restartRequired: true };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// POST /api/identity - merge identity/port fields into toolfunnel.json (unknown fields in the
|
|
733
|
+
// file are PRESERVED). A string field set to '' removes it (falls back to the default); ports
|
|
734
|
+
// must be 1..65535 or '' to remove. Changes take effect on the gateway's NEXT start (the server
|
|
735
|
+
// config is read at boot), which the UI states - no silent expectation of a live change.
|
|
736
|
+
// Preserved fields are NOT re-validated here: an invalid-typed value already in the file (e.g.
|
|
737
|
+
// serverName:42) rides along untouched - deliberate, because loadServerConfig's per-field
|
|
738
|
+
// tolerance is the single defence layer and it falls back to the default for exactly such
|
|
739
|
+
// fields at boot. Re-validating on write would silently EDIT config the operator didn't touch.
|
|
740
|
+
function apiSetIdentity(body) {
|
|
741
|
+
const b = body || {};
|
|
742
|
+
const STR_FIELDS = ['serverName', 'serverVersion', 'clientName', 'clientVersion'];
|
|
743
|
+
const PORT_FIELDS = ['httpPort', 'uiPort'];
|
|
744
|
+
try {
|
|
745
|
+
let file = {};
|
|
746
|
+
try {
|
|
747
|
+
const parsed = JSON.parse(fs.readFileSync(TOOLFUNNEL_JSON, 'utf8'));
|
|
748
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) file = parsed;
|
|
749
|
+
} catch (_e) { /* start from empty */ }
|
|
750
|
+
for (const f of STR_FIELDS) {
|
|
751
|
+
if (!(f in b)) continue;
|
|
752
|
+
const v = b[f];
|
|
753
|
+
if (v === '' || v === null) { delete file[f]; continue; }
|
|
754
|
+
if (typeof v !== 'string' || v.trim().length === 0) {
|
|
755
|
+
return { status: 400, json: { ok: false, error: f + ' must be a non-empty string (or empty to reset)' } };
|
|
756
|
+
}
|
|
757
|
+
file[f] = v.trim();
|
|
758
|
+
}
|
|
759
|
+
for (const f of PORT_FIELDS) {
|
|
760
|
+
if (!(f in b)) continue;
|
|
761
|
+
const v = b[f];
|
|
762
|
+
if (v === '' || v === null) { delete file[f]; continue; }
|
|
763
|
+
const n = Number(v);
|
|
764
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
765
|
+
return { status: 400, json: { ok: false, error: f + ' must be an integer 1..65535 (or empty to reset)' } };
|
|
766
|
+
}
|
|
767
|
+
file[f] = n;
|
|
768
|
+
}
|
|
769
|
+
// serveLegacy (modern-only policy): only an EXPLICIT false ever lands in the file -
|
|
770
|
+
// matching the loader's explicit-false-only flip. true/'' removes the field (the
|
|
771
|
+
// serve-both default), so an untouched config never carries policy it didn't ask for.
|
|
772
|
+
if ('serveLegacy' in b) {
|
|
773
|
+
const v = b.serveLegacy;
|
|
774
|
+
if (v === false || v === 'false') file.serveLegacy = false;
|
|
775
|
+
else delete file.serveLegacy;
|
|
776
|
+
}
|
|
777
|
+
atomicWriteJson(TOOLFUNNEL_JSON, file);
|
|
778
|
+
return { status: 200, json: { ok: true, file, effective: loadServerConfig(root), restartRequired: true } };
|
|
779
|
+
} catch (err) {
|
|
780
|
+
return { status: 500, json: { ok: false, error: (err && err.message) || 'identity update failed' } };
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// GET /api/status -> { tools, upstreams, hooks }.
|
|
785
|
+
function apiStatus() {
|
|
786
|
+
let tools = 0;
|
|
787
|
+
let upstreams = 0;
|
|
788
|
+
let hooks = 0;
|
|
789
|
+
try {
|
|
790
|
+
tools = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT }).list().length;
|
|
791
|
+
} catch (_e) { /* report 0 on a malformed register */ }
|
|
792
|
+
try {
|
|
793
|
+
upstreams = loadExposeStore(EXPOSE_PATH).listUpstreams().length;
|
|
794
|
+
} catch (_e) { /* report 0 */ }
|
|
795
|
+
hooks = readManifest().hooks.length;
|
|
796
|
+
return { tools, upstreams, hooks };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// ── Activity log (logger.js - toggleable JSONL; never throws) ────────────────────────────────────
|
|
800
|
+
// The logger is its own self-gating store (DEFAULT OFF). getConfig/tail are contracted never to
|
|
801
|
+
// throw; setConfig is the writer and CAN throw, so its handler wraps it into a clean 500.
|
|
802
|
+
|
|
803
|
+
// GET /api/logs -> { entries:[...] } the last 100 parsed records (file order, oldest->newest).
|
|
804
|
+
function apiLogs() {
|
|
805
|
+
let entries = [];
|
|
806
|
+
try {
|
|
807
|
+
const t = logger.tail(100);
|
|
808
|
+
entries = Array.isArray(t) ? t : [];
|
|
809
|
+
} catch (_e) { /* tail is contracted not to throw; stay empty on any surprise */ }
|
|
810
|
+
return { entries };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// GET /api/logs/config -> { enabled, path } the resolved logger config (missing file -> disabled).
|
|
814
|
+
function apiLogsConfig() {
|
|
815
|
+
try {
|
|
816
|
+
return logger.getConfig();
|
|
817
|
+
} catch (_e) {
|
|
818
|
+
return { enabled: false, path: '' };
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// POST /api/logs/config { enabled?, path? } -> logger.setConfig (atomic merge). Validates field
|
|
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.
|
|
826
|
+
function apiSetLogsConfig(body) {
|
|
827
|
+
const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
828
|
+
const patch = {};
|
|
829
|
+
if (Object.prototype.hasOwnProperty.call(b, 'enabled')) {
|
|
830
|
+
if (typeof b.enabled !== 'boolean') {
|
|
831
|
+
return { status: 400, json: { ok: false, error: 'enabled must be a boolean' } };
|
|
832
|
+
}
|
|
833
|
+
patch.enabled = b.enabled;
|
|
834
|
+
}
|
|
835
|
+
if (Object.prototype.hasOwnProperty.call(b, 'path')) {
|
|
836
|
+
if (typeof b.path !== 'string' || b.path.length === 0) {
|
|
837
|
+
return { status: 400, json: { ok: false, error: 'path must be a non-empty string' } };
|
|
838
|
+
}
|
|
839
|
+
patch.path = b.path;
|
|
840
|
+
}
|
|
841
|
+
try {
|
|
842
|
+
const config = logger.setConfig(patch);
|
|
843
|
+
return { status: 200, json: { ok: true, config } };
|
|
844
|
+
} catch (err) {
|
|
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' } };
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ── OAuth 2.1 (OPTIONAL; default OFF) - auth config + on-demand jose install ────────────────────
|
|
851
|
+
// No secrets here: issuer / audience / jwksUri are public identifiers (the JWKS itself is public),
|
|
852
|
+
// so the full config is safe to surface. `joseInstalled` + `configError` + `ready` drive the panel.
|
|
853
|
+
|
|
854
|
+
// GET /api/auth -> { config, joseInstalled, josePin, configError, ready }.
|
|
855
|
+
function apiAuth() {
|
|
856
|
+
let config;
|
|
857
|
+
try { config = authConfig.getConfig(); } catch (_e) { config = { enabled: false }; }
|
|
858
|
+
let jose = false;
|
|
859
|
+
try { jose = isJoseInstalled(); } catch (_e) { jose = false; }
|
|
860
|
+
let cfgErr = null;
|
|
861
|
+
try { cfgErr = authConfig.configError(config); } catch (_e) { cfgErr = null; }
|
|
862
|
+
// "ready" = if enabled, jose is present AND the config is coherent (the host would start).
|
|
863
|
+
const ready = !config.enabled || (jose && !cfgErr);
|
|
864
|
+
return { config, joseInstalled: jose, josePin: JOSE_PIN, configError: cfgErr, ready };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// POST /api/auth/config { enabled?, issuer?, audience?, jwksUri?, algorithms?, requiredScopes?,
|
|
868
|
+
// clockToleranceSec? } -> validate field types (clean 400 on a bad shape) then authConfig.setConfig
|
|
869
|
+
// (atomic merge). Permissive about partial configuration (you may set issuer now, audience later);
|
|
870
|
+
// the response reports joseInstalled + configError + ready so the UI can warn that an ENABLED but
|
|
871
|
+
// not-yet-ready config will be refused by the HTTP host at start.
|
|
872
|
+
function apiSetAuthConfig(body) {
|
|
873
|
+
const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
874
|
+
const patch = {};
|
|
875
|
+
const bad = (msg) => ({ status: 400, json: { ok: false, error: msg } });
|
|
876
|
+
|
|
877
|
+
if (Object.prototype.hasOwnProperty.call(b, 'enabled')) {
|
|
878
|
+
if (typeof b.enabled !== 'boolean') return bad('enabled must be a boolean');
|
|
879
|
+
patch.enabled = b.enabled;
|
|
880
|
+
}
|
|
881
|
+
for (const f of ['issuer', 'audience', 'jwksUri']) {
|
|
882
|
+
if (Object.prototype.hasOwnProperty.call(b, f)) {
|
|
883
|
+
if (typeof b[f] !== 'string') return bad(f + ' must be a string');
|
|
884
|
+
patch[f] = b[f];
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
for (const f of ['algorithms', 'requiredScopes']) {
|
|
888
|
+
if (Object.prototype.hasOwnProperty.call(b, f)) {
|
|
889
|
+
if (!Array.isArray(b[f]) || b[f].some((x) => typeof x !== 'string')) return bad(f + ' must be an array of strings');
|
|
890
|
+
patch[f] = b[f];
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (Object.prototype.hasOwnProperty.call(b, 'clockToleranceSec')) {
|
|
894
|
+
if (typeof b.clockToleranceSec !== 'number' || !Number.isFinite(b.clockToleranceSec) || b.clockToleranceSec < 0) {
|
|
895
|
+
return bad('clockToleranceSec must be a non-negative number');
|
|
896
|
+
}
|
|
897
|
+
patch.clockToleranceSec = b.clockToleranceSec;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Guard the classic footgun: enabling auth with no jose installed would make the HTTP host refuse
|
|
901
|
+
// to start. Block it with an actionable message rather than writing a config that breaks the host.
|
|
902
|
+
if (patch.enabled === true && !isJoseInstalled()) {
|
|
903
|
+
return bad('install the OAuth dependency first (click Install, or run `toolfunnel install-oauth`) before enabling auth');
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
let config;
|
|
907
|
+
try {
|
|
908
|
+
config = authConfig.setConfig(patch);
|
|
909
|
+
} catch (err) {
|
|
910
|
+
return { status: 500, json: { ok: false, error: (err && err.message) || 'auth config write failed' } };
|
|
911
|
+
}
|
|
912
|
+
const cfgErr = authConfig.configError(config);
|
|
913
|
+
const ready = !config.enabled || (isJoseInstalled() && !cfgErr);
|
|
914
|
+
return { status: 200, json: { ok: true, config, joseInstalled: isJoseInstalled(), configError: cfgErr, ready } };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// POST /api/oauth/install -> install the single optional dependency (jose@PIN) on demand. Async +
|
|
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;
|
|
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() {
|
|
939
|
+
let res;
|
|
940
|
+
try {
|
|
941
|
+
res = await installJose();
|
|
942
|
+
} catch (err) {
|
|
943
|
+
return { status: 500, json: { ok: false, error: (err && err.message) || 'install failed' } };
|
|
944
|
+
}
|
|
945
|
+
// Trim npm's chatty output so the panel shows a useful tail without flooding the response.
|
|
946
|
+
const tail = (s) => (typeof s === 'string' ? s.slice(-2000) : '');
|
|
947
|
+
return {
|
|
948
|
+
status: res.ok ? 200 : 500,
|
|
949
|
+
json: { ok: res.ok, message: res.message, code: res.code, stdout: tail(res.stdout), stderr: tail(res.stderr), joseInstalled: isJoseInstalled() },
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ── Tool register writes (registry.js - atomic via the store) ──────────────────────────────────
|
|
954
|
+
|
|
955
|
+
// POST /api/tools/add {entry} -> registry.add. When entry.scriptText is supplied for a SCRIPT
|
|
956
|
+
// invoke the body is authored under tools/scripts/<basename> FIRST (registry.writeScript, which
|
|
957
|
+
// is path-guarded), then the entry is registered. registry.add validates id/name/invoke/mode and
|
|
958
|
+
// rejects a duplicate id. Never throws - a bad shape becomes a clean 400.
|
|
959
|
+
function apiToolAdd(body) {
|
|
960
|
+
const entry = body && body.entry;
|
|
961
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
962
|
+
return { status: 400, json: { ok: false, error: 'entry (object) is required' } };
|
|
963
|
+
}
|
|
964
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
965
|
+
try {
|
|
966
|
+
const scriptText = entry.scriptText;
|
|
967
|
+
const clean = { ...entry };
|
|
968
|
+
delete clean.scriptText; // scriptText is a UI affordance, not a register field
|
|
969
|
+
// Author the script body before registering, so a script invoke points at a real file.
|
|
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;
|
|
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
|
+
}
|
|
989
|
+
}
|
|
990
|
+
const added = registry.add(clean);
|
|
991
|
+
return { status: 200, json: { ok: true, entry: added } };
|
|
992
|
+
} catch (err) {
|
|
993
|
+
return { status: 400, json: { ok: false, error: (err && err.message) || 'tool add failed' } };
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// POST /api/tools/remove {id} -> registry.remove + clearToolState (drop the enabled/hidden overlay
|
|
998
|
+
// key so a re-added id starts clean). Unknown id -> 404.
|
|
999
|
+
function apiToolRemove(body) {
|
|
1000
|
+
const id = body && body.id;
|
|
1001
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
1002
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1003
|
+
}
|
|
1004
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
1005
|
+
try {
|
|
1006
|
+
registry.remove(id);
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
return { status: 404, json: { ok: false, error: (err && err.message) || 'tool remove failed' } };
|
|
1009
|
+
}
|
|
1010
|
+
// Best-effort overlay cleanup - a failure here must not undo the register removal.
|
|
1011
|
+
try { clearToolState(TOOL_STATE_PATH, id); } catch (_e) { /* overlay cleanup is best-effort */ }
|
|
1012
|
+
return { status: 200, json: { ok: true } };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// POST /api/tools/mode {id, mode} -> registry.update(id,{mode}). Switching a reference tool that has
|
|
1016
|
+
// no invoke to "gateway" fails validation (nothing to run) and surfaces as a clean 400.
|
|
1017
|
+
function apiToolMode(body) {
|
|
1018
|
+
const id = body && body.id;
|
|
1019
|
+
const mode = body && body.mode;
|
|
1020
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
1021
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1022
|
+
}
|
|
1023
|
+
if (mode !== 'reference' && mode !== 'gateway') {
|
|
1024
|
+
return { status: 400, json: { ok: false, error: 'mode must be "reference" or "gateway"' } };
|
|
1025
|
+
}
|
|
1026
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
1027
|
+
try {
|
|
1028
|
+
const updated = registry.update(id, { mode });
|
|
1029
|
+
return { status: 200, json: { ok: true, mode: resolveMode(updated) } };
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
return { status: 400, json: { ok: false, error: (err && err.message) || 'tool mode update failed' } };
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// GET /api/tools/detail?id=<id> -> the FULL entry (name, summary, category, instructions, invoke,
|
|
1036
|
+
// mode) plus the script BODY for a script invoke, so the UI can show + edit everything about one
|
|
1037
|
+
// tool. Read fresh; unknown id -> 404.
|
|
1038
|
+
function apiToolDetail(id) {
|
|
1039
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
1040
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1041
|
+
}
|
|
1042
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
1043
|
+
let entry;
|
|
1044
|
+
try {
|
|
1045
|
+
entry = registry.getEntry(id);
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
return { status: 404, json: { ok: false, error: (err && err.message) || `unknown tool id "${id}"` } };
|
|
1048
|
+
}
|
|
1049
|
+
// For a script invoke, read the body so the user can view/edit it. Resolve strictly inside the
|
|
1050
|
+
// scripts root (defence-in-depth via basename); a missing/unreadable file leaves scriptText null.
|
|
1051
|
+
let scriptText = null;
|
|
1052
|
+
try {
|
|
1053
|
+
if (entry.invoke && entry.invoke.type === 'script' && entry.invoke.path) {
|
|
1054
|
+
const sp = path.join(SCRIPTS_ROOT, path.basename(entry.invoke.path));
|
|
1055
|
+
if (fs.existsSync(sp)) scriptText = fs.readFileSync(sp, 'utf8');
|
|
1056
|
+
}
|
|
1057
|
+
} catch (_e) { /* leave scriptText null on any read failure */ }
|
|
1058
|
+
return { status: 200, json: { ok: true, entry, scriptText } };
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// POST /api/tools/update {id, patch:{name?,summary?,category?,instructions?,mode?,invoke?}, scriptText?}
|
|
1062
|
+
// -> registry.update (shallow-merge + re-validate + atomic persist). When scriptText is supplied for
|
|
1063
|
+
// a script invoke, the body is authored under tools/scripts/ (path-guarded) first. A bad shape /
|
|
1064
|
+
// unknown id surfaces as a clean 400/404 (registry.update throws -> caught).
|
|
1065
|
+
function apiToolUpdate(body) {
|
|
1066
|
+
const id = body && body.id;
|
|
1067
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
1068
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1069
|
+
}
|
|
1070
|
+
const patch = body && body.patch && typeof body.patch === 'object' && !Array.isArray(body.patch) ? body.patch : {};
|
|
1071
|
+
const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
|
|
1072
|
+
if (!registry.has(id)) {
|
|
1073
|
+
return { status: 404, json: { ok: false, error: `unknown tool id "${id}"` } };
|
|
1074
|
+
}
|
|
1075
|
+
try {
|
|
1076
|
+
// Author the script body first (if provided) so the file matches the (possibly updated) invoke.
|
|
1077
|
+
if (typeof body.scriptText === 'string') {
|
|
1078
|
+
const inv = (patch.invoke && patch.invoke.type) ? patch.invoke : registry.getEntry(id).invoke;
|
|
1079
|
+
if (inv && inv.type === 'script' && inv.path) registry.writeScript(inv.path, body.scriptText);
|
|
1080
|
+
}
|
|
1081
|
+
const updated = registry.update(id, patch);
|
|
1082
|
+
return { status: 200, json: { ok: true, entry: updated } };
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
return { status: 400, json: { ok: false, error: (err && err.message) || 'tool update failed' } };
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// ── Hook manifest writes (hook-loader.js - atomic; overlay-aware) ───────────────────────────────
|
|
1089
|
+
|
|
1090
|
+
/** The relative scripts/<file> a hook command references, if any (so a scriptText lands where the
|
|
1091
|
+
* command will run it). Returns null when the command names no scripts/ file. */
|
|
1092
|
+
function deriveHookScriptRel(command) {
|
|
1093
|
+
if (typeof command !== 'string') return null;
|
|
1094
|
+
const m = command.match(/scripts[\\/]([A-Za-z0-9._-]+)/);
|
|
1095
|
+
return m ? 'scripts/' + m[1] : null;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// GET /api/hooks -> { hooks:[{ id,event,matcher,enabled,description }] }. loadManifest applies the
|
|
1099
|
+
// hooks.state.json overlay over the manifest seed, so `enabled` is the LIVE state. Tool Pre/Post
|
|
1100
|
+
// gates authored via /api/tools/hook have no id and are surfaced with id:"" (managed on the Tools tab).
|
|
1101
|
+
function apiHooks() {
|
|
1102
|
+
let loader;
|
|
1103
|
+
try {
|
|
1104
|
+
loader = loadManifest(MANIFEST_PATH);
|
|
1105
|
+
} catch (_e) {
|
|
1106
|
+
return { hooks: [] };
|
|
1107
|
+
}
|
|
1108
|
+
return {
|
|
1109
|
+
hooks: loader.hooks.map((h) => ({
|
|
1110
|
+
id: typeof h.id === 'string' ? h.id : '',
|
|
1111
|
+
event: h.event || '',
|
|
1112
|
+
matcher: h.matcher == null ? '' : String(h.matcher),
|
|
1113
|
+
enabled: h.enabled === true,
|
|
1114
|
+
description: h.description || '',
|
|
1115
|
+
})),
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// POST /api/hooks/add {entry} -> hookLoader.addEntry (validates id/event/command + rejects dup id).
|
|
1120
|
+
// When entry.scriptText is supplied the body is authored under hooks/scripts/ AFTER the entry exists
|
|
1121
|
+
// (writeScript resolves the path from the entry's `script`, derived from the command if not given).
|
|
1122
|
+
function apiHookAdd(body) {
|
|
1123
|
+
const entry = body && body.entry;
|
|
1124
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
1125
|
+
return { status: 400, json: { ok: false, error: 'entry (object) is required' } };
|
|
1126
|
+
}
|
|
1127
|
+
let loader;
|
|
1128
|
+
try {
|
|
1129
|
+
loader = loadManifest(MANIFEST_PATH);
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
return { status: 500, json: { ok: false, error: 'cannot read hook manifest: ' + ((err && err.message) || err) } };
|
|
1132
|
+
}
|
|
1133
|
+
try {
|
|
1134
|
+
const scriptText = entry.scriptText;
|
|
1135
|
+
const spec = { ...entry };
|
|
1136
|
+
delete spec.scriptText;
|
|
1137
|
+
// If a script body is supplied but no explicit `script`, derive it from the command so the
|
|
1138
|
+
// file written is the one the command runs.
|
|
1139
|
+
if (typeof scriptText === 'string' && scriptText.length > 0 && (typeof spec.script !== 'string' || !spec.script)) {
|
|
1140
|
+
const rel = deriveHookScriptRel(spec.command);
|
|
1141
|
+
if (rel) spec.script = rel;
|
|
1142
|
+
}
|
|
1143
|
+
loader.addEntry(spec); // validates id/event/command; throws on duplicate id
|
|
1144
|
+
if (typeof scriptText === 'string' && scriptText.length > 0) {
|
|
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
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
return { status: 200, json: { ok: true, id: spec.id } };
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
return { status: 400, json: { ok: false, error: (err && err.message) || 'hook add failed' } };
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// POST /api/hooks/state {id, action} -> enable/disable (setEnabled, overlay-backed) or remove
|
|
1162
|
+
// (removeEntry). Unknown id -> 404. setEnabled does not itself verify existence, so we check first
|
|
1163
|
+
// to avoid writing a stray overlay key for a non-existent hook.
|
|
1164
|
+
function apiHookState(body) {
|
|
1165
|
+
const id = body && body.id;
|
|
1166
|
+
const action = body && body.action;
|
|
1167
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
1168
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1169
|
+
}
|
|
1170
|
+
if (action !== 'enable' && action !== 'disable' && action !== 'remove') {
|
|
1171
|
+
return { status: 400, json: { ok: false, error: 'action must be "enable", "disable", or "remove"' } };
|
|
1172
|
+
}
|
|
1173
|
+
let loader;
|
|
1174
|
+
try {
|
|
1175
|
+
loader = loadManifest(MANIFEST_PATH);
|
|
1176
|
+
} catch (err) {
|
|
1177
|
+
return { status: 500, json: { ok: false, error: 'cannot read hook manifest: ' + ((err && err.message) || err) } };
|
|
1178
|
+
}
|
|
1179
|
+
try {
|
|
1180
|
+
if (action === 'remove') {
|
|
1181
|
+
const removed = loader.removeEntry(id);
|
|
1182
|
+
if (!removed) {
|
|
1183
|
+
return { status: 404, json: { ok: false, error: `unknown hook id "${id}"` } };
|
|
1184
|
+
}
|
|
1185
|
+
} else {
|
|
1186
|
+
if (!loader.getSpec(id)) {
|
|
1187
|
+
return { status: 404, json: { ok: false, error: `unknown hook id "${id}"` } };
|
|
1188
|
+
}
|
|
1189
|
+
loader.setEnabled(id, action === 'enable');
|
|
1190
|
+
}
|
|
1191
|
+
return { status: 200, json: { ok: true } };
|
|
1192
|
+
} catch (err) {
|
|
1193
|
+
return { status: 400, json: { ok: false, error: (err && err.message) || 'hook state update failed' } };
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// ── MCP upstream / expose writes (expose-store.js - atomic) ─────────────────────────────────────
|
|
1198
|
+
|
|
1199
|
+
// POST /api/mcp/add {upstream, expose?:[...]} -> addUpstream then addExpose per item. addUpstream
|
|
1200
|
+
// validates a unique non-empty id, transport, and a stdio command. Each expose item is keyed to the
|
|
1201
|
+
// new upstream id.
|
|
1202
|
+
function apiMcpAdd(body) {
|
|
1203
|
+
const upstream = body && body.upstream;
|
|
1204
|
+
if (!upstream || typeof upstream !== 'object' || Array.isArray(upstream)) {
|
|
1205
|
+
return { status: 400, json: { ok: false, error: 'upstream (object) is required' } };
|
|
1206
|
+
}
|
|
1207
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
1208
|
+
try {
|
|
1209
|
+
const added = store.addUpstream(upstream);
|
|
1210
|
+
const items = Array.isArray(body && body.expose) ? body.expose : [];
|
|
1211
|
+
const exposed = [];
|
|
1212
|
+
for (const item of items) {
|
|
1213
|
+
exposed.push(store.addExpose({ ...item, upstream: added.id }));
|
|
1214
|
+
}
|
|
1215
|
+
return { status: 200, json: { ok: true, upstream: added, expose: exposed } };
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
return { status: 400, json: { ok: false, error: (err && err.message) || 'mcp add failed' } };
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// POST /api/mcp/state {id, action} -> enable/disable (setUpstreamEnabled) or remove (removeUpstream,
|
|
1222
|
+
// which cascades the upstream's expose entries). Unknown id -> 404.
|
|
1223
|
+
function apiMcpState(body) {
|
|
1224
|
+
const id = body && body.id;
|
|
1225
|
+
const action = body && body.action;
|
|
1226
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
1227
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1228
|
+
}
|
|
1229
|
+
if (action !== 'enable' && action !== 'disable' && action !== 'remove' && action !== 'pin' && action !== 'unpin') {
|
|
1230
|
+
return { status: 400, json: { ok: false, error: 'action must be "enable", "disable", "remove", "pin", or "unpin"' } };
|
|
1231
|
+
}
|
|
1232
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
1233
|
+
try {
|
|
1234
|
+
if (action === 'remove') {
|
|
1235
|
+
store.removeUpstream(id);
|
|
1236
|
+
} else if (action === 'pin' || action === 'unpin') {
|
|
1237
|
+
// legacyPin (0.6.0): keep speaking MCP 2024-11-05 to this upstream forever - opt-in,
|
|
1238
|
+
// warns at startup and per call. Takes effect on the upstream's NEXT (re)connect.
|
|
1239
|
+
store.updateUpstream(id, { legacyPin: action === 'pin' });
|
|
1240
|
+
return { status: 200, json: { ok: true, legacyPin: action === 'pin' } };
|
|
1241
|
+
} else {
|
|
1242
|
+
store.setUpstreamEnabled(id, action === 'enable');
|
|
1243
|
+
}
|
|
1244
|
+
// Disabling/removing the WRAPPED upstream would brick the surface (empty list, no in-band
|
|
1245
|
+
// recovery) - auto-clear the wrap so the funnel comes back.
|
|
1246
|
+
let wrapCleared = false;
|
|
1247
|
+
if (action !== 'enable') {
|
|
1248
|
+
try {
|
|
1249
|
+
if (getPassthrough(loadToolState(TOOL_STATE_PATH)) === id) {
|
|
1250
|
+
setPassthrough(TOOL_STATE_PATH, null);
|
|
1251
|
+
wrapCleared = true;
|
|
1252
|
+
}
|
|
1253
|
+
} catch (_e) { /* never let wrap-cleanup sink the primary action */ }
|
|
1254
|
+
}
|
|
1255
|
+
return { status: 200, json: { ok: true, ...(wrapCleared ? { wrapCleared: true } : {}) } };
|
|
1256
|
+
} catch (err) {
|
|
1257
|
+
return { status: 404, json: { ok: false, error: (err && err.message) || 'mcp state update failed' } };
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// POST /api/mcp/discover {id} -> LIVE connect + tools/list of one upstream (the "Test / Discover"
|
|
1262
|
+
// button). Constructs a throwaway Aggregator over the SAME store (so the isolation guard applies),
|
|
1263
|
+
// connects + lists, then closes it. Returns each discovered tool with its SURFACED name (an enabled
|
|
1264
|
+
// expose `as` else `<id>_<tool>` - exactly the name the lean list / matrix use) and its current
|
|
1265
|
+
// lean (enabled) + hot state, so the UI can curate per upstream tool. A connect/list failure (bad
|
|
1266
|
+
// command, crash, isolation) is a real user-facing outcome -> 200 { ok:false, error }, not a 500.
|
|
1267
|
+
async function apiMcpDiscover(body) {
|
|
1268
|
+
const id = body && body.id;
|
|
1269
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
1270
|
+
return { status: 400, json: { ok: false, error: 'id (non-empty string) is required' } };
|
|
1271
|
+
}
|
|
1272
|
+
const store = loadExposeStore(EXPOSE_PATH);
|
|
1273
|
+
if (!store.getUpstream(id)) {
|
|
1274
|
+
return { status: 404, json: { ok: false, error: `unknown upstream "${id}"` } };
|
|
1275
|
+
}
|
|
1276
|
+
// Same wrap exemption as the live gateway: the WRAPPED upstream may reference paths outside
|
|
1277
|
+
// the root (transparent-wrapper mode). Without this provider the throwaway Aggregator
|
|
1278
|
+
// hard-refused exactly the upstreams the wrap security notice tells operators to curate
|
|
1279
|
+
// HERE - per-tool enabled:false via this discover.
|
|
1280
|
+
const agg = new Aggregator({
|
|
1281
|
+
store, gatewayRoot: root,
|
|
1282
|
+
wrapTargetProvider: () => {
|
|
1283
|
+
try { return getPassthrough(loadToolState(TOOL_STATE_PATH)); } catch (_e) { return null; }
|
|
1284
|
+
},
|
|
1285
|
+
});
|
|
1286
|
+
let tools;
|
|
1287
|
+
try {
|
|
1288
|
+
// discover() connects the upstream lazily (isolation-guarded), lists its tools, caches+returns.
|
|
1289
|
+
// Race a timeout so a wedged upstream never hangs the UI request (closeAll reaps the child).
|
|
1290
|
+
tools = await withTimeout(agg.discover(id), 20000, `discover "${id}" timed out`);
|
|
1291
|
+
} catch (err) {
|
|
1292
|
+
try { await agg.closeAll(); } catch (_e) { /* best-effort teardown */ }
|
|
1293
|
+
return { status: 200, json: { ok: false, id, error: (err && err.message) || 'discover failed' } };
|
|
1294
|
+
}
|
|
1295
|
+
try { await agg.closeAll(); } catch (_e) { /* best-effort teardown */ }
|
|
1296
|
+
|
|
1297
|
+
const state = loadToolState(TOOL_STATE_PATH);
|
|
1298
|
+
const enabledExposed = store.listExposed({ upstream: id, enabledOnly: true });
|
|
1299
|
+
const items = (Array.isArray(tools) ? tools : []).map((t) => {
|
|
1300
|
+
const surfaced = surfacedNameFor(store, enabledExposed, id, t && t.name);
|
|
1301
|
+
return {
|
|
1302
|
+
tool: (t && t.name) || '',
|
|
1303
|
+
name: surfaced,
|
|
1304
|
+
description: (t && typeof t.description === 'string') ? t.description : '',
|
|
1305
|
+
enabled: isToolEnabled(state, surfaced),
|
|
1306
|
+
hot: isToolHot(state, surfaced, false),
|
|
1307
|
+
};
|
|
1308
|
+
});
|
|
1309
|
+
return { status: 200, json: { ok: true, id, tools: items } };
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// The POST routing table - path -> handler. Each handler takes the parsed body and returns
|
|
1313
|
+
// { status, json } (or a Promise of it - the dispatcher awaits); none throws (every store call is
|
|
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
|
+
};
|
|
1328
|
+
const POST_HANDLERS = {
|
|
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.
|
|
1340
|
+
'/api/mcp/discover': apiMcpDiscover,
|
|
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).
|
|
1347
|
+
'/api/oauth/install': apiOauthInstall,
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
// ── HTTP plumbing ───────────────────────────────────────────────────────────────────────────
|
|
1351
|
+
|
|
1352
|
+
/** Read a request body up to MAX_BODY_BYTES, then JSON.parse. Resolves {} for an empty body. */
|
|
1353
|
+
function readJsonBody(req) {
|
|
1354
|
+
return new Promise((resolve, reject) => {
|
|
1355
|
+
let size = 0;
|
|
1356
|
+
const chunks = [];
|
|
1357
|
+
let done = false;
|
|
1358
|
+
const finish = (fn, val) => { if (!done) { done = true; fn(val); } };
|
|
1359
|
+
req.on('data', (chunk) => {
|
|
1360
|
+
size += chunk.length;
|
|
1361
|
+
if (size > MAX_BODY_BYTES) {
|
|
1362
|
+
finish(reject, new Error('request body too large'));
|
|
1363
|
+
try { req.destroy(); } catch (_e) { /* ignore */ }
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
chunks.push(chunk);
|
|
1367
|
+
});
|
|
1368
|
+
req.on('end', () => {
|
|
1369
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
1370
|
+
if (raw.length === 0) return finish(resolve, {});
|
|
1371
|
+
try {
|
|
1372
|
+
finish(resolve, JSON.parse(raw));
|
|
1373
|
+
} catch (_e) {
|
|
1374
|
+
finish(reject, new Error('invalid JSON body'));
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
req.on('error', (err) => finish(reject, err));
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/** Write a JSON response. Defensive: a serialise failure degrades to a 500 text body. */
|
|
1382
|
+
function sendJson(res, status, obj) {
|
|
1383
|
+
let bodyStr;
|
|
1384
|
+
try {
|
|
1385
|
+
bodyStr = JSON.stringify(obj);
|
|
1386
|
+
} catch (_e) {
|
|
1387
|
+
bodyStr = '{"ok":false,"error":"failed to serialise response"}';
|
|
1388
|
+
status = 500;
|
|
1389
|
+
}
|
|
1390
|
+
res.writeHead(status, {
|
|
1391
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1392
|
+
'Content-Length': Buffer.byteLength(bodyStr, 'utf8'),
|
|
1393
|
+
'Cache-Control': 'no-store',
|
|
1394
|
+
});
|
|
1395
|
+
res.end(bodyStr);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/** Serve a static file from PUBLIC_DIR (read FRESH). 404 JSON when absent / outside the dir. */
|
|
1399
|
+
function sendStatic(res, relName) {
|
|
1400
|
+
// Resolve strictly inside PUBLIC_DIR (defence-in-depth against traversal).
|
|
1401
|
+
const resolved = path.resolve(PUBLIC_DIR, relName);
|
|
1402
|
+
const baseWithSep = PUBLIC_DIR.endsWith(path.sep) ? PUBLIC_DIR : PUBLIC_DIR + path.sep;
|
|
1403
|
+
if (resolved !== PUBLIC_DIR && !resolved.startsWith(baseWithSep)) {
|
|
1404
|
+
return sendJson(res, 403, { ok: false, error: 'forbidden' });
|
|
1405
|
+
}
|
|
1406
|
+
let data;
|
|
1407
|
+
try {
|
|
1408
|
+
data = fs.readFileSync(resolved);
|
|
1409
|
+
} catch (_e) {
|
|
1410
|
+
return sendJson(res, 404, { ok: false, error: 'not found: ' + relName });
|
|
1411
|
+
}
|
|
1412
|
+
const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream';
|
|
1413
|
+
res.writeHead(200, {
|
|
1414
|
+
'Content-Type': type,
|
|
1415
|
+
'Content-Length': data.length,
|
|
1416
|
+
'Cache-Control': 'no-store',
|
|
1417
|
+
});
|
|
1418
|
+
res.end(data);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** The single request listener. Dispatches by method + path. NEVER throws. */
|
|
1422
|
+
async function onRequest(req, res) {
|
|
1423
|
+
try {
|
|
1424
|
+
// DNS-rebinding / non-loopback guard (defence-in-depth on top of the bind address).
|
|
1425
|
+
if (!isLoopbackHost(req.headers && req.headers.host)) {
|
|
1426
|
+
return sendJson(res, 403, { ok: false, error: 'forbidden: non-loopback Host' });
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const rawUrl = req.url || '/';
|
|
1430
|
+
const qIndex = rawUrl.indexOf('?');
|
|
1431
|
+
const pathName = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
|
|
1432
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
1433
|
+
|
|
1434
|
+
// CSRF guard. The bind address stops the NETWORK, not the browser: any web page the
|
|
1435
|
+
// operator visits can fire a no-cors POST at 127.0.0.1:<port>, and the mutating routes
|
|
1436
|
+
// below write tool scripts and register process-spawning upstreams. Browsers ALWAYS send
|
|
1437
|
+
// an Origin header on cross-origin POSTs, so: a PRESENT Origin must itself be loopback
|
|
1438
|
+
// (the UI's own pages), while an ABSENT Origin (curl, CLI clients, non-browser tools) is
|
|
1439
|
+
// allowed - for those, the bind address remains the boundary. GETs stay open (read-only).
|
|
1440
|
+
if (method !== 'GET') {
|
|
1441
|
+
const origin = req.headers && req.headers.origin;
|
|
1442
|
+
if (origin) {
|
|
1443
|
+
let originHost = null;
|
|
1444
|
+
try { originHost = new URL(origin).host; } catch (_e) { /* malformed -> reject below */ }
|
|
1445
|
+
if (!originHost || !isLoopbackHost(originHost)) {
|
|
1446
|
+
return sendJson(res, 403, { ok: false, error: 'forbidden: cross-origin request (CSRF guard)' });
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// ── Static assets ─────────────────────────────────────────────────────────────────────────
|
|
1452
|
+
if (method === 'GET' && (pathName === '/' || pathName === '/index.html')) {
|
|
1453
|
+
return sendStatic(res, 'index.html');
|
|
1454
|
+
}
|
|
1455
|
+
if (method === 'GET' && pathName === '/app.js') {
|
|
1456
|
+
return sendStatic(res, 'app.js');
|
|
1457
|
+
}
|
|
1458
|
+
if (method === 'GET' && pathName === '/styles.css') {
|
|
1459
|
+
return sendStatic(res, 'styles.css');
|
|
1460
|
+
}
|
|
1461
|
+
// Image assets (logo, favicon, ...): serve any safe-named image file from public/. The filename
|
|
1462
|
+
// regex + sendStatic's traversal guard keep this strictly inside the public dir.
|
|
1463
|
+
if (method === 'GET' && /^\/[A-Za-z0-9._-]+\.(jpg|jpeg|png|svg|ico|webp)$/.test(pathName)) {
|
|
1464
|
+
return sendStatic(res, pathName.slice(1));
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// ── JSON API (read fresh each call) ─────────────────────────────────────────────────────────
|
|
1468
|
+
if (method === 'GET' && pathName === '/api/tools') {
|
|
1469
|
+
return sendJson(res, 200, apiTools());
|
|
1470
|
+
}
|
|
1471
|
+
if (method === 'GET' && pathName === '/api/tools/detail') {
|
|
1472
|
+
let id = '';
|
|
1473
|
+
try {
|
|
1474
|
+
const qi = rawUrl.indexOf('?');
|
|
1475
|
+
id = qi === -1 ? '' : (new URLSearchParams(rawUrl.slice(qi + 1)).get('id') || '');
|
|
1476
|
+
} catch (_e) { id = ''; }
|
|
1477
|
+
const d = apiToolDetail(id);
|
|
1478
|
+
return sendJson(res, d.status, d.json);
|
|
1479
|
+
}
|
|
1480
|
+
if (method === 'GET' && pathName === '/api/upstreams') {
|
|
1481
|
+
return sendJson(res, 200, apiUpstreams());
|
|
1482
|
+
}
|
|
1483
|
+
if (method === 'GET' && pathName === '/api/identity') {
|
|
1484
|
+
return sendJson(res, 200, apiIdentity());
|
|
1485
|
+
}
|
|
1486
|
+
if (method === 'GET' && pathName === '/api/hooks') {
|
|
1487
|
+
return sendJson(res, 200, apiHooks());
|
|
1488
|
+
}
|
|
1489
|
+
if (method === 'GET' && pathName === '/api/status') {
|
|
1490
|
+
return sendJson(res, 200, apiStatus());
|
|
1491
|
+
}
|
|
1492
|
+
if (method === 'GET' && pathName === '/api/surface') {
|
|
1493
|
+
return sendJson(res, 200, apiSurface());
|
|
1494
|
+
}
|
|
1495
|
+
if (method === 'GET' && pathName === '/api/logs') {
|
|
1496
|
+
return sendJson(res, 200, apiLogs());
|
|
1497
|
+
}
|
|
1498
|
+
if (method === 'GET' && pathName === '/api/logs/config') {
|
|
1499
|
+
return sendJson(res, 200, apiLogsConfig());
|
|
1500
|
+
}
|
|
1501
|
+
if (method === 'GET' && pathName === '/api/auth') {
|
|
1502
|
+
return sendJson(res, 200, apiAuth());
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
if (method === 'POST' && Object.prototype.hasOwnProperty.call(POST_HANDLERS, pathName)) {
|
|
1506
|
+
let body;
|
|
1507
|
+
try {
|
|
1508
|
+
body = await readJsonBody(req);
|
|
1509
|
+
} catch (err) {
|
|
1510
|
+
return sendJson(res, 400, { ok: false, error: (err && err.message) || 'bad request body' });
|
|
1511
|
+
}
|
|
1512
|
+
// Handlers may be sync OR async (apiMcpDiscover connects an upstream) - await covers both.
|
|
1513
|
+
const result = await POST_HANDLERS[pathName](body);
|
|
1514
|
+
// Audit a config-MUTATING write that SUCCEEDED (self-gating - no-op unless logging is on).
|
|
1515
|
+
if (result && result.status >= 200 && result.status < 300 && result.json && result.json.ok === true && CONFIG_EVENTS[pathName]) {
|
|
1516
|
+
logConfigChange(CONFIG_EVENTS[pathName](body || {}));
|
|
1517
|
+
}
|
|
1518
|
+
return sendJson(res, result.status, result.json);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
return sendJson(res, 404, { ok: false, error: 'not found: ' + method + ' ' + pathName });
|
|
1522
|
+
} catch (err) {
|
|
1523
|
+
// Last-resort guard: any unexpected failure becomes a clean 500 JSON, never a crash.
|
|
1524
|
+
logErr('onRequest error:', (err && err.stack) || String(err));
|
|
1525
|
+
try {
|
|
1526
|
+
sendJson(res, 500, { ok: false, error: (err && err.message) || 'internal error' });
|
|
1527
|
+
} catch (_e) {
|
|
1528
|
+
/* response may already be committed */
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────────────────────────
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* start - bind the HTTP server. On EADDRINUSE the returned promise REJECTS with a clear error
|
|
1537
|
+
* (the caller decides) - it does NOT crash the process. Resolves to { url, port } once listening.
|
|
1538
|
+
* Port 0 -> an OS-assigned ephemeral port (read back from server.address()).
|
|
1539
|
+
* @returns {Promise<{url:string, port:number}>}
|
|
1540
|
+
*/
|
|
1541
|
+
function start() {
|
|
1542
|
+
if (started) return Promise.resolve({ url: currentUrl(), port: boundPort });
|
|
1543
|
+
// The HTTP transport refuses a non-loopback bind unless OAuth is enabled; the UI has NO auth
|
|
1544
|
+
// path, so its refusal is unconditional. This endpoint spawns processes and writes scripts -
|
|
1545
|
+
// "--ui --host 0.0.0.0" would hand an unauthenticated remote console to the LAN.
|
|
1546
|
+
if (!isLoopbackBindHost(host)) {
|
|
1547
|
+
return Promise.reject(new Error(
|
|
1548
|
+
`refusing to bind the config UI to non-loopback host "${host}" - the UI is unauthenticated ` +
|
|
1549
|
+
'and can spawn processes / write tool scripts. It is loopback-only by design; to configure ' +
|
|
1550
|
+
'the gateway from another machine, use an SSH tunnel to 127.0.0.1 instead.'
|
|
1551
|
+
));
|
|
1552
|
+
}
|
|
1553
|
+
httpServer = http.createServer(onRequest);
|
|
1554
|
+
return new Promise((resolve, reject) => {
|
|
1555
|
+
const onListenError = (err) => {
|
|
1556
|
+
httpServer.removeListener('listening', onListening);
|
|
1557
|
+
httpServer = null;
|
|
1558
|
+
reject(err);
|
|
1559
|
+
};
|
|
1560
|
+
const onListening = () => {
|
|
1561
|
+
httpServer.removeListener('error', onListenError);
|
|
1562
|
+
const addr = httpServer.address();
|
|
1563
|
+
boundPort = addr && typeof addr === 'object' ? addr.port : requestedPort;
|
|
1564
|
+
started = true;
|
|
1565
|
+
// Swap the one-shot bind-error handler for a steady-state one so a later socket error
|
|
1566
|
+
// (a client RST, etc.) is logged, not thrown.
|
|
1567
|
+
httpServer.on('error', (e) => logErr('http server error:', (e && e.message) || String(e)));
|
|
1568
|
+
resolve({ url: currentUrl(), port: boundPort });
|
|
1569
|
+
};
|
|
1570
|
+
httpServer.once('error', onListenError);
|
|
1571
|
+
httpServer.once('listening', onListening);
|
|
1572
|
+
httpServer.listen(requestedPort, host);
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/** stop - close the HTTP server. Idempotent and NEVER throws. */
|
|
1577
|
+
function stop() {
|
|
1578
|
+
return new Promise((resolve) => {
|
|
1579
|
+
if (!httpServer) {
|
|
1580
|
+
started = false;
|
|
1581
|
+
boundPort = null;
|
|
1582
|
+
return resolve();
|
|
1583
|
+
}
|
|
1584
|
+
const srv = httpServer;
|
|
1585
|
+
httpServer = null;
|
|
1586
|
+
let settled = false;
|
|
1587
|
+
const done = () => { if (!settled) { settled = true; started = false; boundPort = null; resolve(); } };
|
|
1588
|
+
try {
|
|
1589
|
+
srv.close(done);
|
|
1590
|
+
} catch (_e) {
|
|
1591
|
+
done();
|
|
1592
|
+
}
|
|
1593
|
+
try {
|
|
1594
|
+
if (typeof srv.closeAllConnections === 'function') srv.closeAllConnections();
|
|
1595
|
+
} catch (_e) {
|
|
1596
|
+
/* closeAllConnections is Node ≥18.2; absent -> close() still resolves */
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
return {
|
|
1602
|
+
start,
|
|
1603
|
+
stop,
|
|
1604
|
+
get url() {
|
|
1605
|
+
return currentUrl();
|
|
1606
|
+
},
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
module.exports = {
|
|
1611
|
+
createUiServer,
|
|
1612
|
+
// Exported for unit tests / reuse - the small pure seams.
|
|
1613
|
+
isLoopbackHost,
|
|
1614
|
+
isLoopbackBindHost,
|
|
1615
|
+
escapeRegex,
|
|
1616
|
+
};
|