@klhapp/skillmux 1.9.1 → 1.9.3
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/CHANGELOG.md +25 -0
- package/docs/configuration.md +51 -0
- package/docs/deployment.md +10 -0
- package/docs/skill-management.md +54 -0
- package/package.json +1 -1
- package/src/cli.ts +87 -34
- package/src/clients.ts +17 -0
- package/src/commands/audit.ts +9 -3
- package/src/commands/outdated.ts +6 -4
- package/src/commands/update.ts +9 -4
- package/src/concurrency-limiter.ts +61 -0
- package/src/config.ts +17 -0
- package/src/db.ts +114 -6
- package/src/doctor.ts +25 -1
- package/src/install.ts +15 -0
- package/src/rate-limiter.ts +29 -2
- package/src/redact.ts +52 -0
- package/src/server.ts +488 -275
- package/src/types.ts +7 -0
package/src/server.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { createClients } from "./clients";
|
|
7
|
-
import { loadConfig, resolveConfigPath } from "./config";
|
|
7
|
+
import { isLoopbackBindHost, loadConfig, resolveConfigPath } from "./config";
|
|
8
8
|
import { describeDeployment } from "./deployment";
|
|
9
9
|
import { ConfigWatcher, type ReloadStatus } from "./config-watcher";
|
|
10
10
|
import { RuntimeSnapshotManager } from "./snapshot";
|
|
@@ -21,6 +21,8 @@ import { SKILL_ID_PATTERN } from "./vault";
|
|
|
21
21
|
import { MetricsRegistry } from "./metrics";
|
|
22
22
|
import { ReadinessState } from "./readiness";
|
|
23
23
|
import { initializeRuntime } from "./lifecycle";
|
|
24
|
+
import { buildRedactor } from "./redact";
|
|
25
|
+
import { insertAdminAuditRow, type AdminAuditChange } from "./db";
|
|
24
26
|
import type { Clients, Config } from "./types";
|
|
25
27
|
import {
|
|
26
28
|
computeHash,
|
|
@@ -34,12 +36,57 @@ import {
|
|
|
34
36
|
export const metricsRegistry = new MetricsRegistry();
|
|
35
37
|
export const readinessState = new ReadinessState();
|
|
36
38
|
|
|
39
|
+
// runtime-resource-hardening: positive defaults so the http transport is
|
|
40
|
+
// never unbounded by omission, unlike the opt-in [egress] allowlist.
|
|
41
|
+
const DEFAULT_MAX_BODY_BYTES = 1_048_576; // 1 MiB
|
|
42
|
+
const DEFAULT_MAX_CONCURRENT_REQUESTS = 100;
|
|
43
|
+
|
|
44
|
+
/** Pairs a fixed log prefix with a redacted error message, for console.error. */
|
|
45
|
+
export function redactedErrorLog(
|
|
46
|
+
prefix: string,
|
|
47
|
+
err: unknown,
|
|
48
|
+
redact: (text: string) => string,
|
|
49
|
+
): [string, string] {
|
|
50
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
51
|
+
return [prefix, redact(msg)];
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
export interface ServerHandle {
|
|
38
55
|
port?: number;
|
|
56
|
+
statsPort?: number;
|
|
39
57
|
reloadStatus(): ReloadStatus;
|
|
40
58
|
stop(): Promise<void>;
|
|
41
59
|
}
|
|
42
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Docker's documented deployment mode auto-switches hostname to "0.0.0.0"
|
|
63
|
+
* (config.ts's RUNNING_IN_DOCKER override) while server.auth_enabled still
|
|
64
|
+
* defaults to false and allowed_origins defaults to [] — which only blocks
|
|
65
|
+
* requests that carry an Origin header, so a plain server-to-server/curl
|
|
66
|
+
* request sails through unauthenticated. That default combination leaves MCP
|
|
67
|
+
* tools (resolve_skill/fetch_skill) and /stats (raw historical query text)
|
|
68
|
+
* open to anyone who can reach the port — the documented common case, not an
|
|
69
|
+
* edge case (SMX-91). Refuse to start rather than silently exposing it;
|
|
70
|
+
* SKILLMUX_ALLOW_INSECURE_BIND is the explicit, logged escape hatch for
|
|
71
|
+
* operators who rely on network-level isolation instead of application auth.
|
|
72
|
+
*/
|
|
73
|
+
export function assertSafeBindPosture(
|
|
74
|
+
hostname: string,
|
|
75
|
+
authEnabled: boolean,
|
|
76
|
+
env: Record<string, string | undefined> = process.env,
|
|
77
|
+
): void {
|
|
78
|
+
if (isLoopbackBindHost(hostname) || authEnabled) return;
|
|
79
|
+
const message =
|
|
80
|
+
`refusing to bind "${hostname}" (reachable beyond this machine) with server.auth_enabled=false — ` +
|
|
81
|
+
"MCP tools (resolve_skill/fetch_skill) and /stats would be open to anyone who can reach this port. " +
|
|
82
|
+
"Set server.auth_enabled=true (with SKILLMUX_AUTH_TOKEN) or bind a loopback hostname instead. " +
|
|
83
|
+
"To start anyway — e.g. when network isolation is the intended boundary — set SKILLMUX_ALLOW_INSECURE_BIND=true.";
|
|
84
|
+
if (env.SKILLMUX_ALLOW_INSECURE_BIND !== "true") {
|
|
85
|
+
throw new Error(`skillmux: ${message}`);
|
|
86
|
+
}
|
|
87
|
+
console.error(`skillmux: WARNING — ${message}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
43
90
|
let warnedAuthToken = false;
|
|
44
91
|
function resolveAuthToken(envName: string): string {
|
|
45
92
|
const value = process.env[envName];
|
|
@@ -59,11 +106,108 @@ function resolveAuthToken(envName: string): string {
|
|
|
59
106
|
return "";
|
|
60
107
|
}
|
|
61
108
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
109
|
+
// SMX-94: comparing raw buffers made length itself observable — a
|
|
110
|
+
// mismatched-length pair returns before ever reaching timingSafeEqual.
|
|
111
|
+
// Hashing both sides to a fixed 32-byte digest first means every
|
|
112
|
+
// comparison takes the same constant-time path regardless of input length.
|
|
113
|
+
export function safeTokenEquals(a: string, b: string): boolean {
|
|
114
|
+
const hashA = createHash("sha256").update(a).digest();
|
|
115
|
+
const hashB = createHash("sha256").update(b).digest();
|
|
116
|
+
return timingSafeEqual(hashA, hashB);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A second, narrow HTTP listener exposing only GET /health and GET /stats —
|
|
121
|
+
* nothing from the MCP tool surface or /admin/v1/*. Lets a stdio deployment
|
|
122
|
+
* (no HTTP transport at all) still answer `skillmux report --server ...`
|
|
123
|
+
* remotely, without switching its primary transport. Reuses the exact same
|
|
124
|
+
* bind-posture guard, auth-token check, and rate limiter as the http
|
|
125
|
+
* transport's /stats route (server.ts's main Bun.serve handler) rather than
|
|
126
|
+
* inventing a separate security model for this listener.
|
|
127
|
+
*/
|
|
128
|
+
async function serveStatsOnly(opts: {
|
|
129
|
+
config: Config;
|
|
130
|
+
port: number;
|
|
131
|
+
}): Promise<{ port: number | undefined; stop(): void }> {
|
|
132
|
+
const serverConfig = opts.config.server || {
|
|
133
|
+
auth_enabled: false,
|
|
134
|
+
auth_token_env: "SKILLMUX_AUTH_TOKEN",
|
|
135
|
+
allowed_origins: [],
|
|
136
|
+
};
|
|
137
|
+
const hostname = serverConfig.hostname ?? "127.0.0.1";
|
|
138
|
+
assertSafeBindPosture(hostname, serverConfig.auth_enabled ?? false);
|
|
139
|
+
|
|
140
|
+
const { RateLimiter } = await import("./rate-limiter");
|
|
141
|
+
const rateLimiter = new RateLimiter(
|
|
142
|
+
serverConfig.rate_limit || { enabled: false, requests_per_minute: 60 },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const bunServer = Bun.serve({
|
|
146
|
+
port: opts.port,
|
|
147
|
+
hostname,
|
|
148
|
+
async fetch(req, server) {
|
|
149
|
+
const rateLimitResult = rateLimiter.check({
|
|
150
|
+
nowMs: Date.now(),
|
|
151
|
+
auth_enabled: serverConfig.auth_enabled,
|
|
152
|
+
req,
|
|
153
|
+
server,
|
|
154
|
+
});
|
|
155
|
+
if (!rateLimitResult.allowed) {
|
|
156
|
+
return new Response("Too Many Requests", {
|
|
157
|
+
status: 429,
|
|
158
|
+
headers: rateLimitResult.headers,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const url = new URL(req.url);
|
|
163
|
+
// GET /health stays open (unauthenticated) even when auth_enabled — it
|
|
164
|
+
// carries no data, matching the http transport's /health, which returns
|
|
165
|
+
// before its own Token Auth Check for the same reason.
|
|
166
|
+
if (req.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) {
|
|
167
|
+
return new Response(JSON.stringify({ status: "ok" }), {
|
|
168
|
+
status: 200,
|
|
169
|
+
headers: { "Content-Type": "application/json", ...rateLimitResult.headers },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (serverConfig.auth_enabled) {
|
|
174
|
+
const expectedToken = resolveAuthToken(serverConfig.auth_token_env);
|
|
175
|
+
if (!expectedToken) {
|
|
176
|
+
return new Response(
|
|
177
|
+
"Server authentication configured but token environment variable is empty",
|
|
178
|
+
{ status: 500 },
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const authHeader = req.headers.get("authorization") || "";
|
|
182
|
+
const token = authHeader.startsWith("Bearer ")
|
|
183
|
+
? authHeader.slice(7)
|
|
184
|
+
: authHeader;
|
|
185
|
+
if (!token || !safeTokenEquals(token, expectedToken)) {
|
|
186
|
+
return new Response("Unauthorized", { status: 401 });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (req.method === "GET" && url.pathname === "/stats") {
|
|
191
|
+
const since = url.searchParams.get("since") ?? "";
|
|
192
|
+
if (!SINCE_PATTERN.test(since)) {
|
|
193
|
+
return new Response(
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
error: "since must be a relative window (e.g. 30d) or an absolute ISO-8601 date",
|
|
196
|
+
}),
|
|
197
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const { auditDb } = await getRuntime();
|
|
201
|
+
return new Response(JSON.stringify(getStats(auditDb, since)), {
|
|
202
|
+
status: 200,
|
|
203
|
+
headers: { "Content-Type": "application/json", ...rateLimitResult.headers },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return new Response("Not Found", { status: 404 });
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return { port: bunServer.port, stop: () => bunServer.stop(true) };
|
|
67
211
|
}
|
|
68
212
|
|
|
69
213
|
export function createMcpServer(): McpServer {
|
|
@@ -142,12 +286,14 @@ export function createMcpServer(): McpServer {
|
|
|
142
286
|
export async function startServer(opts?: {
|
|
143
287
|
transport?: "stdio" | "http";
|
|
144
288
|
port?: number;
|
|
289
|
+
statsPort?: number;
|
|
145
290
|
config?: Config;
|
|
146
291
|
clients?: Partial<Clients>;
|
|
147
292
|
configPath?: string;
|
|
148
293
|
}): Promise<ServerHandle> {
|
|
149
294
|
const configPath = resolveConfigPath(opts?.configPath);
|
|
150
295
|
const config = opts?.config ?? (await loadConfig(configPath));
|
|
296
|
+
const redact = buildRedactor(config);
|
|
151
297
|
const initialClients = { ...createClients(config), ...opts?.clients };
|
|
152
298
|
const snapshots = RuntimeSnapshotManager.create(config, initialClients);
|
|
153
299
|
const inactiveReloadStatus: ReloadStatus = {
|
|
@@ -172,18 +318,18 @@ export async function startServer(opts?: {
|
|
|
172
318
|
configure({ config: nextConfig, clients: nextClients });
|
|
173
319
|
},
|
|
174
320
|
onError: (error) =>
|
|
175
|
-
console.error("skillmux config reload error:", error),
|
|
321
|
+
console.error(...redactedErrorLog("skillmux config reload error:", error, redact)),
|
|
176
322
|
})
|
|
177
323
|
: undefined;
|
|
178
324
|
const stopWatcher = await startVaultWatcher();
|
|
179
325
|
const initPromise = initializeRuntime(readinessState)
|
|
180
326
|
.then(() => metricsRegistry.setReadiness(readinessState.get()))
|
|
181
|
-
.catch((err) => console.error("skillmux runtime init error:", err));
|
|
327
|
+
.catch((err) => console.error(...redactedErrorLog("skillmux runtime init error:", err, redact)));
|
|
182
328
|
|
|
183
329
|
// AC14: fire-and-forget so this never delays readiness or blocks a resolve;
|
|
184
330
|
// not chained onto initPromise, which is awaited below for HTTP transport.
|
|
185
331
|
const runAuditPrune = () =>
|
|
186
|
-
pruneAuditIfDue().catch((err) => console.error("skillmux audit prune error:", err));
|
|
332
|
+
pruneAuditIfDue().catch((err) => console.error(...redactedErrorLog("skillmux audit prune error:", err, redact)));
|
|
187
333
|
runAuditPrune();
|
|
188
334
|
const auditPruneInterval = setInterval(runAuditPrune, 24 * 60 * 60 * 1000);
|
|
189
335
|
auditPruneInterval.unref();
|
|
@@ -191,6 +337,16 @@ export async function startServer(opts?: {
|
|
|
191
337
|
const server = createMcpServer();
|
|
192
338
|
|
|
193
339
|
const transportType = opts?.transport ?? "stdio";
|
|
340
|
+
if (opts?.statsPort !== undefined && transportType === "http") {
|
|
341
|
+
throw new Error(
|
|
342
|
+
"skillmux: --stats-port is not supported with --transport http; the http transport already serves /stats on --port",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
const statsHandle =
|
|
346
|
+
opts?.statsPort !== undefined
|
|
347
|
+
? await serveStatsOnly({ config, port: opts.statsPort })
|
|
348
|
+
: undefined;
|
|
349
|
+
|
|
194
350
|
if (transportType === "http") {
|
|
195
351
|
const { WebStandardStreamableHTTPServerTransport } =
|
|
196
352
|
await import("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js");
|
|
@@ -203,325 +359,378 @@ export async function startServer(opts?: {
|
|
|
203
359
|
const rateLimiter = new RateLimiter(
|
|
204
360
|
config.server?.rate_limit || { enabled: false, requests_per_minute: 60 },
|
|
205
361
|
);
|
|
362
|
+
const { ConcurrencyLimiter, releaseOnStreamClose } = await import("./concurrency-limiter");
|
|
363
|
+
const concurrencyLimiter = new ConcurrencyLimiter(
|
|
364
|
+
config.server?.max_concurrent_requests ?? DEFAULT_MAX_CONCURRENT_REQUESTS,
|
|
365
|
+
);
|
|
206
366
|
|
|
207
367
|
const port = opts?.port ?? Number(process.env.PORT || 3000);
|
|
208
368
|
const hostname = config.server?.hostname ?? "127.0.0.1";
|
|
369
|
+
assertSafeBindPosture(hostname, config.server?.auth_enabled ?? false);
|
|
209
370
|
const bunServer = Bun.serve({
|
|
210
371
|
port,
|
|
211
372
|
hostname,
|
|
373
|
+
maxRequestBodySize: config.server?.max_body_bytes ?? DEFAULT_MAX_BODY_BYTES,
|
|
212
374
|
async fetch(req, server) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
allowedOrigins.includes("*") || allowedOrigins.includes(origin);
|
|
222
|
-
const allowOriginHeader = isAllowed
|
|
223
|
-
? allowedOrigins.includes("*")
|
|
224
|
-
? "*"
|
|
225
|
-
: origin
|
|
226
|
-
: "";
|
|
227
|
-
|
|
228
|
-
if (origin && !isAllowed) {
|
|
229
|
-
return new Response("CORS origin not allowed", { status: 403 });
|
|
375
|
+
// AC3: a positive bound on in-flight requests, checked before any other
|
|
376
|
+
// work — protects against connection exhaustion the same way
|
|
377
|
+
// maxRequestBodySize protects against a single oversized request.
|
|
378
|
+
if (!concurrencyLimiter.tryAcquire()) {
|
|
379
|
+
return new Response("Service Unavailable", {
|
|
380
|
+
status: 503,
|
|
381
|
+
headers: { "Retry-After": "1" },
|
|
382
|
+
});
|
|
230
383
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
384
|
+
try {
|
|
385
|
+
const res = await handleHttpRequest(req, server);
|
|
386
|
+
// Defer the release until the response body actually finishes —
|
|
387
|
+
// a buffered body drains almost immediately, but an open SSE
|
|
388
|
+
// stream (the MCP transport's notification channel and streaming
|
|
389
|
+
// replies) can stay open long after this async function returns,
|
|
390
|
+
// and the slot must reflect that real connection lifetime.
|
|
391
|
+
const body = releaseOnStreamClose(res.body, () => concurrencyLimiter.release());
|
|
392
|
+
return new Response(body, {
|
|
393
|
+
status: res.status,
|
|
394
|
+
statusText: res.statusText,
|
|
395
|
+
headers: res.headers,
|
|
240
396
|
});
|
|
397
|
+
} catch (error) {
|
|
398
|
+
concurrencyLimiter.release();
|
|
399
|
+
throw error;
|
|
241
400
|
}
|
|
242
401
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
// Non-JSON or parsing error
|
|
402
|
+
async function handleHttpRequest(
|
|
403
|
+
req: Request,
|
|
404
|
+
server: { requestIP(request: Request): { address: string } | null },
|
|
405
|
+
): Promise<Response> {
|
|
406
|
+
const serverConfig = config.server || {
|
|
407
|
+
auth_enabled: false,
|
|
408
|
+
auth_token_env: "SKILLMUX_AUTH_TOKEN",
|
|
409
|
+
allowed_origins: [],
|
|
410
|
+
};
|
|
411
|
+
const origin = req.headers.get("origin") || "";
|
|
412
|
+
const allowedOrigins = serverConfig.allowed_origins;
|
|
413
|
+
const isAllowed =
|
|
414
|
+
allowedOrigins.includes("*") || allowedOrigins.includes(origin);
|
|
415
|
+
const allowOriginHeader = isAllowed
|
|
416
|
+
? allowedOrigins.includes("*")
|
|
417
|
+
? "*"
|
|
418
|
+
: origin
|
|
419
|
+
: "";
|
|
420
|
+
|
|
421
|
+
if (origin && !isAllowed) {
|
|
422
|
+
return new Response("CORS origin not allowed", { status: 403 });
|
|
265
423
|
}
|
|
266
|
-
metricsRegistry.recordRequest(mcpMethod);
|
|
267
424
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
425
|
+
if (req.method === "OPTIONS") {
|
|
426
|
+
return new Response(null, {
|
|
427
|
+
headers: {
|
|
428
|
+
"Access-Control-Allow-Origin": allowOriginHeader,
|
|
429
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
430
|
+
"Access-Control-Allow-Headers":
|
|
431
|
+
"Content-Type, Authorization, MCP-Protocol-Version",
|
|
432
|
+
},
|
|
433
|
+
});
|
|
271
434
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
435
|
+
|
|
436
|
+
// Run rate limiter check
|
|
437
|
+
const rateLimitResult = rateLimiter.check({
|
|
438
|
+
nowMs: Date.now(),
|
|
439
|
+
auth_enabled: serverConfig.auth_enabled,
|
|
440
|
+
req,
|
|
441
|
+
server,
|
|
275
442
|
});
|
|
276
|
-
}
|
|
277
443
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
444
|
+
if (!rateLimitResult.allowed) {
|
|
445
|
+
metricsRegistry.recordRateLimitExceeded();
|
|
446
|
+
|
|
447
|
+
// Count the request in requests_total under the method if possible
|
|
448
|
+
let mcpMethod = "unknown";
|
|
449
|
+
try {
|
|
450
|
+
const bodyClone = await req.clone().json();
|
|
451
|
+
if (bodyClone.method === "tools/call") {
|
|
452
|
+
mcpMethod = bodyClone.params?.name || "tools/call";
|
|
453
|
+
} else {
|
|
454
|
+
mcpMethod = bodyClone.method || "unknown";
|
|
455
|
+
}
|
|
456
|
+
} catch {
|
|
457
|
+
// Non-JSON or parsing error
|
|
458
|
+
}
|
|
459
|
+
metricsRegistry.recordRequest(mcpMethod);
|
|
460
|
+
|
|
461
|
+
const headers = new Headers(rateLimitResult.headers);
|
|
282
462
|
if (allowOriginHeader) {
|
|
283
463
|
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
284
464
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
)) {
|
|
288
|
-
headers.set(key, value);
|
|
289
|
-
}
|
|
290
|
-
return new Response(JSON.stringify({ status: "ok" }), {
|
|
291
|
-
status: 200,
|
|
465
|
+
return new Response("Too Many Requests", {
|
|
466
|
+
status: 429,
|
|
292
467
|
headers,
|
|
293
468
|
});
|
|
294
469
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
470
|
+
|
|
471
|
+
const url = new URL(req.url);
|
|
472
|
+
if (req.method === "GET") {
|
|
473
|
+
if (url.pathname === "/health" || url.pathname === "/health/live") {
|
|
474
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
475
|
+
if (allowOriginHeader) {
|
|
476
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
477
|
+
}
|
|
478
|
+
for (const [key, value] of Object.entries(
|
|
479
|
+
rateLimitResult.headers,
|
|
480
|
+
)) {
|
|
481
|
+
headers.set(key, value);
|
|
482
|
+
}
|
|
483
|
+
return new Response(JSON.stringify({ status: "ok" }), {
|
|
484
|
+
status: 200,
|
|
485
|
+
headers,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (url.pathname === "/health/ready") {
|
|
489
|
+
const readiness = readinessState.get();
|
|
490
|
+
const deployment = describeDeployment(config);
|
|
491
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
492
|
+
if (allowOriginHeader)
|
|
493
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
494
|
+
return new Response(JSON.stringify({
|
|
495
|
+
...readiness,
|
|
496
|
+
version: deployment.version,
|
|
497
|
+
runtime: deployment.runtime,
|
|
498
|
+
image_variant: deployment.image_variant,
|
|
499
|
+
}), {
|
|
500
|
+
status: readiness.status === "ready" ? 200 : 503,
|
|
501
|
+
headers,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (url.pathname === "/metrics") {
|
|
505
|
+
const headers = new Headers({
|
|
506
|
+
"Content-Type": "text/plain; version=0.0.4",
|
|
507
|
+
});
|
|
508
|
+
if (allowOriginHeader) {
|
|
509
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
510
|
+
}
|
|
511
|
+
for (const [key, value] of Object.entries(
|
|
512
|
+
rateLimitResult.headers,
|
|
513
|
+
)) {
|
|
514
|
+
headers.set(key, value);
|
|
515
|
+
}
|
|
516
|
+
return new Response(metricsRegistry.render(), {
|
|
517
|
+
status: 200,
|
|
518
|
+
headers,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Token Auth Check
|
|
524
|
+
if (serverConfig.auth_enabled) {
|
|
525
|
+
const expectedToken = resolveAuthToken(serverConfig.auth_token_env);
|
|
526
|
+
if (!expectedToken) {
|
|
527
|
+
return new Response(
|
|
528
|
+
"Server authentication configured but token environment variable is empty",
|
|
529
|
+
{ status: 500 },
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
const authHeader = req.headers.get("authorization") || "";
|
|
533
|
+
const token = authHeader.startsWith("Bearer ")
|
|
534
|
+
? authHeader.slice(7)
|
|
535
|
+
: authHeader;
|
|
536
|
+
if (!token || !safeTokenEquals(token, expectedToken)) {
|
|
537
|
+
return new Response("Unauthorized", { status: 401 });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// GET /stats — placed after the Token Auth Check above (unlike /health and /metrics,
|
|
542
|
+
// which return earlier and stay open) since audit queries carry raw user text.
|
|
543
|
+
if (req.method === "GET" && url.pathname === "/stats") {
|
|
544
|
+
const since = url.searchParams.get("since") ?? "";
|
|
545
|
+
if (!SINCE_PATTERN.test(since)) {
|
|
546
|
+
return new Response(
|
|
547
|
+
JSON.stringify({
|
|
548
|
+
error:
|
|
549
|
+
"since must be a relative window (e.g. 30d) or an absolute ISO-8601 date",
|
|
550
|
+
}),
|
|
551
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
const { auditDb } = await getRuntime();
|
|
298
555
|
const headers = new Headers({ "Content-Type": "application/json" });
|
|
299
556
|
if (allowOriginHeader)
|
|
300
557
|
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
301
|
-
|
|
302
|
-
...readiness,
|
|
303
|
-
version: deployment.version,
|
|
304
|
-
runtime: deployment.runtime,
|
|
305
|
-
image_variant: deployment.image_variant,
|
|
306
|
-
}), {
|
|
307
|
-
status: readiness.status === "ready" ? 200 : 503,
|
|
308
|
-
headers,
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
if (url.pathname === "/metrics") {
|
|
312
|
-
const headers = new Headers({
|
|
313
|
-
"Content-Type": "text/plain; version=0.0.4",
|
|
314
|
-
});
|
|
315
|
-
if (allowOriginHeader) {
|
|
316
|
-
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
317
|
-
}
|
|
318
|
-
for (const [key, value] of Object.entries(
|
|
319
|
-
rateLimitResult.headers,
|
|
320
|
-
)) {
|
|
558
|
+
for (const [key, value] of Object.entries(rateLimitResult.headers))
|
|
321
559
|
headers.set(key, value);
|
|
322
|
-
|
|
323
|
-
return new Response(metricsRegistry.render(), {
|
|
560
|
+
return new Response(JSON.stringify(getStats(auditDb, since)), {
|
|
324
561
|
status: 200,
|
|
325
562
|
headers,
|
|
326
563
|
});
|
|
327
564
|
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Token Auth Check
|
|
331
|
-
if (serverConfig.auth_enabled) {
|
|
332
|
-
const expectedToken = resolveAuthToken(serverConfig.auth_token_env);
|
|
333
|
-
if (!expectedToken) {
|
|
334
|
-
return new Response(
|
|
335
|
-
"Server authentication configured but token environment variable is empty",
|
|
336
|
-
{ status: 500 },
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
const authHeader = req.headers.get("authorization") || "";
|
|
340
|
-
const token = authHeader.startsWith("Bearer ")
|
|
341
|
-
? authHeader.slice(7)
|
|
342
|
-
: authHeader;
|
|
343
|
-
if (!token || !safeTokenEquals(token, expectedToken)) {
|
|
344
|
-
return new Response("Unauthorized", { status: 401 });
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// GET /stats — placed after the Token Auth Check above (unlike /health and /metrics,
|
|
349
|
-
// which return earlier and stay open) since audit queries carry raw user text.
|
|
350
|
-
if (req.method === "GET" && url.pathname === "/stats") {
|
|
351
|
-
const since = url.searchParams.get("since") ?? "";
|
|
352
|
-
if (!SINCE_PATTERN.test(since)) {
|
|
353
|
-
return new Response(
|
|
354
|
-
JSON.stringify({
|
|
355
|
-
error:
|
|
356
|
-
"since must be a relative window (e.g. 30d) or an absolute ISO-8601 date",
|
|
357
|
-
}),
|
|
358
|
-
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
const { auditDb } = await getRuntime();
|
|
362
|
-
const headers = new Headers({ "Content-Type": "application/json" });
|
|
363
|
-
if (allowOriginHeader)
|
|
364
|
-
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
365
|
-
for (const [key, value] of Object.entries(rateLimitResult.headers))
|
|
366
|
-
headers.set(key, value);
|
|
367
|
-
return new Response(JSON.stringify(getStats(auditDb, since)), {
|
|
368
|
-
status: 200,
|
|
369
|
-
headers,
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Admin HTTP API (/admin/v1/*)
|
|
374
|
-
if (url.pathname.startsWith("/admin/v1/")) {
|
|
375
|
-
if (!serverConfig.admin?.enabled) {
|
|
376
|
-
return new Response("Admin endpoints disabled", { status: 403 });
|
|
377
|
-
}
|
|
378
565
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
? authHeader.slice(7)
|
|
385
|
-
: authHeader;
|
|
386
|
-
|
|
387
|
-
if (
|
|
388
|
-
!expectedAdminToken ||
|
|
389
|
-
!token ||
|
|
390
|
-
!safeTokenEquals(token, expectedAdminToken)
|
|
391
|
-
) {
|
|
392
|
-
return new Response("Unauthorized", { status: 401 });
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
const headers = new Headers({ "Content-Type": "application/json" });
|
|
396
|
-
if (allowOriginHeader)
|
|
397
|
-
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
566
|
+
// Admin HTTP API (/admin/v1/*)
|
|
567
|
+
if (url.pathname.startsWith("/admin/v1/")) {
|
|
568
|
+
if (!serverConfig.admin?.enabled) {
|
|
569
|
+
return new Response("Admin endpoints disabled", { status: 403 });
|
|
570
|
+
}
|
|
398
571
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}),
|
|
415
|
-
{ status: 200, headers },
|
|
416
|
-
);
|
|
417
|
-
}
|
|
572
|
+
const adminTokenEnv =
|
|
573
|
+
serverConfig.admin.token_env || "SKILLMUX_ADMIN_TOKEN";
|
|
574
|
+
const expectedAdminToken = process.env[adminTokenEnv] || "";
|
|
575
|
+
const authHeader = req.headers.get("authorization") || "";
|
|
576
|
+
const token = authHeader.startsWith("Bearer ")
|
|
577
|
+
? authHeader.slice(7)
|
|
578
|
+
: authHeader;
|
|
579
|
+
|
|
580
|
+
if (
|
|
581
|
+
!expectedAdminToken ||
|
|
582
|
+
!token ||
|
|
583
|
+
!safeTokenEquals(token, expectedAdminToken)
|
|
584
|
+
) {
|
|
585
|
+
return new Response("Unauthorized", { status: 401 });
|
|
586
|
+
}
|
|
418
587
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const desiredHash = computeHash(effective);
|
|
423
|
-
const snapshot = snapshots.acquire();
|
|
424
|
-
const activeRevision = computeHash(snapshot.snapshot.config);
|
|
425
|
-
snapshot.release();
|
|
426
|
-
const status =
|
|
427
|
-
configWatcher?.reloadStatus() ?? inactiveReloadStatus;
|
|
428
|
-
headers.set("ETag", `"${desiredHash}"`);
|
|
429
|
-
return new Response(
|
|
430
|
-
JSON.stringify({
|
|
431
|
-
desired: effective,
|
|
432
|
-
effective,
|
|
433
|
-
sources,
|
|
434
|
-
active_revision: activeRevision,
|
|
435
|
-
runtime: {
|
|
436
|
-
target: "local",
|
|
437
|
-
desired_source: configPath,
|
|
438
|
-
desired_source_hash: desiredHash,
|
|
439
|
-
active_revision: activeRevision,
|
|
440
|
-
active_source_hash: activeRevision,
|
|
441
|
-
...status,
|
|
442
|
-
readiness: readinessState.get(),
|
|
443
|
-
runtime: "running",
|
|
444
|
-
version: deployment.version,
|
|
445
|
-
deployment_runtime: deployment.runtime,
|
|
446
|
-
image_variant: deployment.image_variant,
|
|
447
|
-
},
|
|
448
|
-
}),
|
|
449
|
-
{ status: 200, headers },
|
|
450
|
-
);
|
|
451
|
-
}
|
|
588
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
589
|
+
if (allowOriginHeader)
|
|
590
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
452
591
|
|
|
453
|
-
|
|
454
|
-
|
|
592
|
+
if (
|
|
593
|
+
req.method === "GET" &&
|
|
594
|
+
url.pathname === "/admin/v1/capabilities"
|
|
595
|
+
) {
|
|
596
|
+
const isExternallyManaged =
|
|
597
|
+
process.env.SKILLMUX_CONFIG_READONLY === "true";
|
|
455
598
|
return new Response(
|
|
456
599
|
JSON.stringify({
|
|
457
|
-
|
|
458
|
-
|
|
600
|
+
config_read: true,
|
|
601
|
+
config_write: !isExternallyManaged,
|
|
602
|
+
persistence: isExternallyManaged
|
|
603
|
+
? "externally_managed"
|
|
604
|
+
: "writable",
|
|
605
|
+
reloadable_keys: RELOADABLE_KEYS,
|
|
606
|
+
restart_required_keys: RESTART_REQUIRED_KEYS,
|
|
459
607
|
}),
|
|
460
|
-
{ status:
|
|
608
|
+
{ status: 200, headers },
|
|
461
609
|
);
|
|
462
610
|
}
|
|
463
611
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
612
|
+
if (req.method === "GET" && url.pathname === "/admin/v1/config") {
|
|
613
|
+
const { effective, sources } = await getEffectiveConfig(configPath);
|
|
614
|
+
const deployment = describeDeployment(config);
|
|
615
|
+
const desiredHash = computeHash(effective);
|
|
616
|
+
const snapshot = snapshots.acquire();
|
|
617
|
+
const activeRevision = computeHash(snapshot.snapshot.config);
|
|
618
|
+
snapshot.release();
|
|
619
|
+
const status =
|
|
620
|
+
configWatcher?.reloadStatus() ?? inactiveReloadStatus;
|
|
621
|
+
headers.set("ETag", `"${desiredHash}"`);
|
|
470
622
|
return new Response(
|
|
471
623
|
JSON.stringify({
|
|
472
|
-
|
|
473
|
-
|
|
624
|
+
desired: effective,
|
|
625
|
+
effective,
|
|
626
|
+
sources,
|
|
627
|
+
active_revision: activeRevision,
|
|
628
|
+
runtime: {
|
|
629
|
+
target: "local",
|
|
630
|
+
desired_source: configPath,
|
|
631
|
+
desired_source_hash: desiredHash,
|
|
632
|
+
active_revision: activeRevision,
|
|
633
|
+
active_source_hash: activeRevision,
|
|
634
|
+
...status,
|
|
635
|
+
readiness: readinessState.get(),
|
|
636
|
+
runtime: "running",
|
|
637
|
+
version: deployment.version,
|
|
638
|
+
deployment_runtime: deployment.runtime,
|
|
639
|
+
image_variant: deployment.image_variant,
|
|
640
|
+
},
|
|
474
641
|
}),
|
|
475
|
-
{ status:
|
|
642
|
+
{ status: 200, headers },
|
|
476
643
|
);
|
|
477
644
|
}
|
|
478
645
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
646
|
+
if (req.method === "PATCH" && url.pathname === "/admin/v1/config") {
|
|
647
|
+
if (process.env.SKILLMUX_CONFIG_READONLY === "true") {
|
|
648
|
+
return new Response(
|
|
649
|
+
JSON.stringify({
|
|
650
|
+
error: "CONFIG_EXTERNALLY_MANAGED",
|
|
651
|
+
message: "Configuration is externally managed",
|
|
652
|
+
}),
|
|
653
|
+
{ status: 409, headers },
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const ifMatch = req.headers.get("if-match") || "";
|
|
658
|
+
const cleanIfMatch = ifMatch.replace(/^"|"$/g, "");
|
|
659
|
+
const { effective } = await getEffectiveConfig(configPath);
|
|
660
|
+
const currentHash = computeHash(effective);
|
|
661
|
+
|
|
662
|
+
if (!ifMatch || cleanIfMatch !== currentHash) {
|
|
663
|
+
return new Response(
|
|
664
|
+
JSON.stringify({
|
|
665
|
+
error: "CONFIG_REVISION_CONFLICT",
|
|
666
|
+
message: "Revision conflict",
|
|
667
|
+
}),
|
|
668
|
+
{ status: 409, headers },
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const body = (await req.json()) as {
|
|
673
|
+
changes: Record<string, string | number | boolean>;
|
|
674
|
+
};
|
|
675
|
+
let lastResult: any = null;
|
|
676
|
+
const auditChanges: AdminAuditChange[] = [];
|
|
677
|
+
for (const [k, v] of Object.entries(body.changes ?? {})) {
|
|
678
|
+
lastResult = await setDottedKey(k, String(v), {
|
|
679
|
+
targetName: "remote",
|
|
680
|
+
});
|
|
681
|
+
auditChanges.push({
|
|
682
|
+
key: k,
|
|
683
|
+
old_value: lastResult.prior_val,
|
|
684
|
+
new_value: lastResult.resulting_val,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (auditChanges.length > 0 && lastResult) {
|
|
689
|
+
const { auditDb } = await getRuntime();
|
|
690
|
+
insertAdminAuditRow(auditDb, {
|
|
691
|
+
ts: new Date().toISOString(),
|
|
692
|
+
changes: auditChanges,
|
|
693
|
+
resulting_revision: lastResult.resulting_revision,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return new Response(JSON.stringify(lastResult ?? { ok: true }), {
|
|
698
|
+
status: 200,
|
|
699
|
+
headers,
|
|
486
700
|
});
|
|
487
701
|
}
|
|
488
702
|
|
|
489
|
-
return new Response(
|
|
490
|
-
status: 200,
|
|
491
|
-
headers,
|
|
492
|
-
});
|
|
703
|
+
return new Response("Not Found", { status: 404, headers });
|
|
493
704
|
}
|
|
494
705
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
505
|
-
|
|
706
|
+
// Record request metrics
|
|
707
|
+
let mcpMethod = "unknown";
|
|
708
|
+
try {
|
|
709
|
+
const bodyClone = await req.clone().json();
|
|
710
|
+
if (bodyClone.method === "tools/call") {
|
|
711
|
+
mcpMethod = bodyClone.params?.name || "tools/call";
|
|
712
|
+
} else {
|
|
713
|
+
mcpMethod = bodyClone.method || "unknown";
|
|
714
|
+
}
|
|
715
|
+
} catch {
|
|
716
|
+
// Non-JSON or parsing error
|
|
506
717
|
}
|
|
507
|
-
|
|
508
|
-
// Non-JSON or parsing error
|
|
509
|
-
}
|
|
510
|
-
metricsRegistry.recordRequest(mcpMethod);
|
|
718
|
+
metricsRegistry.recordRequest(mcpMethod);
|
|
511
719
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
720
|
+
const res = await transport.handleRequest(req);
|
|
721
|
+
const headers = new Headers(res.headers);
|
|
722
|
+
if (allowOriginHeader) {
|
|
723
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
724
|
+
}
|
|
725
|
+
for (const [key, value] of Object.entries(rateLimitResult.headers)) {
|
|
726
|
+
headers.set(key, value);
|
|
727
|
+
}
|
|
728
|
+
return new Response(res.body, {
|
|
729
|
+
status: res.status,
|
|
730
|
+
statusText: res.statusText,
|
|
731
|
+
headers,
|
|
732
|
+
});
|
|
519
733
|
}
|
|
520
|
-
return new Response(res.body, {
|
|
521
|
-
status: res.status,
|
|
522
|
-
statusText: res.statusText,
|
|
523
|
-
headers,
|
|
524
|
-
});
|
|
525
734
|
},
|
|
526
735
|
});
|
|
527
736
|
let stopped = false;
|
|
@@ -529,6 +738,7 @@ export async function startServer(opts?: {
|
|
|
529
738
|
console.log(`skillmux serving over HTTP on ${hostname}:${bunServer.port}`);
|
|
530
739
|
return {
|
|
531
740
|
port: bunServer.port,
|
|
741
|
+
statsPort: statsHandle?.port,
|
|
532
742
|
reloadStatus: () =>
|
|
533
743
|
configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
|
|
534
744
|
async stop() {
|
|
@@ -538,6 +748,7 @@ export async function startServer(opts?: {
|
|
|
538
748
|
readinessState.set({ ...readinessState.get(), status: "stopping" });
|
|
539
749
|
metricsRegistry.setReadiness(readinessState.get());
|
|
540
750
|
bunServer.stop(true);
|
|
751
|
+
statsHandle?.stop();
|
|
541
752
|
configWatcher?.stop();
|
|
542
753
|
stopWatcher();
|
|
543
754
|
snapshots.dispose();
|
|
@@ -549,6 +760,7 @@ export async function startServer(opts?: {
|
|
|
549
760
|
await server.connect(new StdioServerTransport());
|
|
550
761
|
let stopped = false;
|
|
551
762
|
return {
|
|
763
|
+
statsPort: statsHandle?.port,
|
|
552
764
|
reloadStatus: () =>
|
|
553
765
|
configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
|
|
554
766
|
async stop() {
|
|
@@ -557,6 +769,7 @@ export async function startServer(opts?: {
|
|
|
557
769
|
clearInterval(auditPruneInterval);
|
|
558
770
|
readinessState.set({ ...readinessState.get(), status: "stopping" });
|
|
559
771
|
metricsRegistry.setReadiness(readinessState.get());
|
|
772
|
+
statsHandle?.stop();
|
|
560
773
|
configWatcher?.stop();
|
|
561
774
|
stopWatcher();
|
|
562
775
|
snapshots.dispose();
|