@klhapp/skillmux 1.9.2 → 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 +15 -0
- package/docs/configuration.md +51 -0
- package/docs/skill-management.md +43 -0
- package/package.json +1 -1
- package/src/cli.ts +75 -31
- 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 +5 -0
- package/src/db.ts +114 -6
- package/src/install.ts +15 -0
- package/src/redact.ts +52 -0
- package/src/server.ts +448 -268
- package/src/types.ts +7 -0
package/src/server.ts
CHANGED
|
@@ -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,8 +36,24 @@ 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
|
}
|
|
@@ -98,6 +116,100 @@ export function safeTokenEquals(a: string, b: string): boolean {
|
|
|
98
116
|
return timingSafeEqual(hashA, hashB);
|
|
99
117
|
}
|
|
100
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) };
|
|
211
|
+
}
|
|
212
|
+
|
|
101
213
|
export function createMcpServer(): McpServer {
|
|
102
214
|
const server = new McpServer({ name: "skillmux", version: "0.1.0" });
|
|
103
215
|
|
|
@@ -174,12 +286,14 @@ export function createMcpServer(): McpServer {
|
|
|
174
286
|
export async function startServer(opts?: {
|
|
175
287
|
transport?: "stdio" | "http";
|
|
176
288
|
port?: number;
|
|
289
|
+
statsPort?: number;
|
|
177
290
|
config?: Config;
|
|
178
291
|
clients?: Partial<Clients>;
|
|
179
292
|
configPath?: string;
|
|
180
293
|
}): Promise<ServerHandle> {
|
|
181
294
|
const configPath = resolveConfigPath(opts?.configPath);
|
|
182
295
|
const config = opts?.config ?? (await loadConfig(configPath));
|
|
296
|
+
const redact = buildRedactor(config);
|
|
183
297
|
const initialClients = { ...createClients(config), ...opts?.clients };
|
|
184
298
|
const snapshots = RuntimeSnapshotManager.create(config, initialClients);
|
|
185
299
|
const inactiveReloadStatus: ReloadStatus = {
|
|
@@ -204,18 +318,18 @@ export async function startServer(opts?: {
|
|
|
204
318
|
configure({ config: nextConfig, clients: nextClients });
|
|
205
319
|
},
|
|
206
320
|
onError: (error) =>
|
|
207
|
-
console.error("skillmux config reload error:", error),
|
|
321
|
+
console.error(...redactedErrorLog("skillmux config reload error:", error, redact)),
|
|
208
322
|
})
|
|
209
323
|
: undefined;
|
|
210
324
|
const stopWatcher = await startVaultWatcher();
|
|
211
325
|
const initPromise = initializeRuntime(readinessState)
|
|
212
326
|
.then(() => metricsRegistry.setReadiness(readinessState.get()))
|
|
213
|
-
.catch((err) => console.error("skillmux runtime init error:", err));
|
|
327
|
+
.catch((err) => console.error(...redactedErrorLog("skillmux runtime init error:", err, redact)));
|
|
214
328
|
|
|
215
329
|
// AC14: fire-and-forget so this never delays readiness or blocks a resolve;
|
|
216
330
|
// not chained onto initPromise, which is awaited below for HTTP transport.
|
|
217
331
|
const runAuditPrune = () =>
|
|
218
|
-
pruneAuditIfDue().catch((err) => console.error("skillmux audit prune error:", err));
|
|
332
|
+
pruneAuditIfDue().catch((err) => console.error(...redactedErrorLog("skillmux audit prune error:", err, redact)));
|
|
219
333
|
runAuditPrune();
|
|
220
334
|
const auditPruneInterval = setInterval(runAuditPrune, 24 * 60 * 60 * 1000);
|
|
221
335
|
auditPruneInterval.unref();
|
|
@@ -223,6 +337,16 @@ export async function startServer(opts?: {
|
|
|
223
337
|
const server = createMcpServer();
|
|
224
338
|
|
|
225
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
|
+
|
|
226
350
|
if (transportType === "http") {
|
|
227
351
|
const { WebStandardStreamableHTTPServerTransport } =
|
|
228
352
|
await import("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js");
|
|
@@ -235,6 +359,10 @@ export async function startServer(opts?: {
|
|
|
235
359
|
const rateLimiter = new RateLimiter(
|
|
236
360
|
config.server?.rate_limit || { enabled: false, requests_per_minute: 60 },
|
|
237
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
|
+
);
|
|
238
366
|
|
|
239
367
|
const port = opts?.port ?? Number(process.env.PORT || 3000);
|
|
240
368
|
const hostname = config.server?.hostname ?? "127.0.0.1";
|
|
@@ -242,319 +370,367 @@ export async function startServer(opts?: {
|
|
|
242
370
|
const bunServer = Bun.serve({
|
|
243
371
|
port,
|
|
244
372
|
hostname,
|
|
373
|
+
maxRequestBodySize: config.server?.max_body_bytes ?? DEFAULT_MAX_BODY_BYTES,
|
|
245
374
|
async fetch(req, server) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
allowedOrigins.includes("*") || allowedOrigins.includes(origin);
|
|
255
|
-
const allowOriginHeader = isAllowed
|
|
256
|
-
? allowedOrigins.includes("*")
|
|
257
|
-
? "*"
|
|
258
|
-
: origin
|
|
259
|
-
: "";
|
|
260
|
-
|
|
261
|
-
if (origin && !isAllowed) {
|
|
262
|
-
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
|
+
});
|
|
263
383
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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,
|
|
273
396
|
});
|
|
397
|
+
} catch (error) {
|
|
398
|
+
concurrencyLimiter.release();
|
|
399
|
+
throw error;
|
|
274
400
|
}
|
|
275
401
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
// 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 });
|
|
298
423
|
}
|
|
299
|
-
metricsRegistry.recordRequest(mcpMethod);
|
|
300
424
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
+
});
|
|
304
434
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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,
|
|
308
442
|
});
|
|
309
|
-
}
|
|
310
443
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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);
|
|
315
462
|
if (allowOriginHeader) {
|
|
316
463
|
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
317
464
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
)) {
|
|
321
|
-
headers.set(key, value);
|
|
322
|
-
}
|
|
323
|
-
return new Response(JSON.stringify({ status: "ok" }), {
|
|
324
|
-
status: 200,
|
|
465
|
+
return new Response("Too Many Requests", {
|
|
466
|
+
status: 429,
|
|
325
467
|
headers,
|
|
326
468
|
});
|
|
327
469
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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();
|
|
331
555
|
const headers = new Headers({ "Content-Type": "application/json" });
|
|
332
556
|
if (allowOriginHeader)
|
|
333
557
|
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
334
|
-
|
|
335
|
-
...readiness,
|
|
336
|
-
version: deployment.version,
|
|
337
|
-
runtime: deployment.runtime,
|
|
338
|
-
image_variant: deployment.image_variant,
|
|
339
|
-
}), {
|
|
340
|
-
status: readiness.status === "ready" ? 200 : 503,
|
|
341
|
-
headers,
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
if (url.pathname === "/metrics") {
|
|
345
|
-
const headers = new Headers({
|
|
346
|
-
"Content-Type": "text/plain; version=0.0.4",
|
|
347
|
-
});
|
|
348
|
-
if (allowOriginHeader) {
|
|
349
|
-
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
350
|
-
}
|
|
351
|
-
for (const [key, value] of Object.entries(
|
|
352
|
-
rateLimitResult.headers,
|
|
353
|
-
)) {
|
|
558
|
+
for (const [key, value] of Object.entries(rateLimitResult.headers))
|
|
354
559
|
headers.set(key, value);
|
|
355
|
-
|
|
356
|
-
return new Response(metricsRegistry.render(), {
|
|
560
|
+
return new Response(JSON.stringify(getStats(auditDb, since)), {
|
|
357
561
|
status: 200,
|
|
358
562
|
headers,
|
|
359
563
|
});
|
|
360
564
|
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Token Auth Check
|
|
364
|
-
if (serverConfig.auth_enabled) {
|
|
365
|
-
const expectedToken = resolveAuthToken(serverConfig.auth_token_env);
|
|
366
|
-
if (!expectedToken) {
|
|
367
|
-
return new Response(
|
|
368
|
-
"Server authentication configured but token environment variable is empty",
|
|
369
|
-
{ status: 500 },
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
const authHeader = req.headers.get("authorization") || "";
|
|
373
|
-
const token = authHeader.startsWith("Bearer ")
|
|
374
|
-
? authHeader.slice(7)
|
|
375
|
-
: authHeader;
|
|
376
|
-
if (!token || !safeTokenEquals(token, expectedToken)) {
|
|
377
|
-
return new Response("Unauthorized", { status: 401 });
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// GET /stats — placed after the Token Auth Check above (unlike /health and /metrics,
|
|
382
|
-
// which return earlier and stay open) since audit queries carry raw user text.
|
|
383
|
-
if (req.method === "GET" && url.pathname === "/stats") {
|
|
384
|
-
const since = url.searchParams.get("since") ?? "";
|
|
385
|
-
if (!SINCE_PATTERN.test(since)) {
|
|
386
|
-
return new Response(
|
|
387
|
-
JSON.stringify({
|
|
388
|
-
error:
|
|
389
|
-
"since must be a relative window (e.g. 30d) or an absolute ISO-8601 date",
|
|
390
|
-
}),
|
|
391
|
-
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
392
|
-
);
|
|
393
|
-
}
|
|
394
|
-
const { auditDb } = await getRuntime();
|
|
395
|
-
const headers = new Headers({ "Content-Type": "application/json" });
|
|
396
|
-
if (allowOriginHeader)
|
|
397
|
-
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
398
|
-
for (const [key, value] of Object.entries(rateLimitResult.headers))
|
|
399
|
-
headers.set(key, value);
|
|
400
|
-
return new Response(JSON.stringify(getStats(auditDb, since)), {
|
|
401
|
-
status: 200,
|
|
402
|
-
headers,
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Admin HTTP API (/admin/v1/*)
|
|
407
|
-
if (url.pathname.startsWith("/admin/v1/")) {
|
|
408
|
-
if (!serverConfig.admin?.enabled) {
|
|
409
|
-
return new Response("Admin endpoints disabled", { status: 403 });
|
|
410
|
-
}
|
|
411
565
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
? authHeader.slice(7)
|
|
418
|
-
: authHeader;
|
|
419
|
-
|
|
420
|
-
if (
|
|
421
|
-
!expectedAdminToken ||
|
|
422
|
-
!token ||
|
|
423
|
-
!safeTokenEquals(token, expectedAdminToken)
|
|
424
|
-
) {
|
|
425
|
-
return new Response("Unauthorized", { status: 401 });
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const headers = new Headers({ "Content-Type": "application/json" });
|
|
429
|
-
if (allowOriginHeader)
|
|
430
|
-
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
|
+
}
|
|
431
571
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}),
|
|
448
|
-
{ status: 200, headers },
|
|
449
|
-
);
|
|
450
|
-
}
|
|
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
|
+
}
|
|
451
587
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const desiredHash = computeHash(effective);
|
|
456
|
-
const snapshot = snapshots.acquire();
|
|
457
|
-
const activeRevision = computeHash(snapshot.snapshot.config);
|
|
458
|
-
snapshot.release();
|
|
459
|
-
const status =
|
|
460
|
-
configWatcher?.reloadStatus() ?? inactiveReloadStatus;
|
|
461
|
-
headers.set("ETag", `"${desiredHash}"`);
|
|
462
|
-
return new Response(
|
|
463
|
-
JSON.stringify({
|
|
464
|
-
desired: effective,
|
|
465
|
-
effective,
|
|
466
|
-
sources,
|
|
467
|
-
active_revision: activeRevision,
|
|
468
|
-
runtime: {
|
|
469
|
-
target: "local",
|
|
470
|
-
desired_source: configPath,
|
|
471
|
-
desired_source_hash: desiredHash,
|
|
472
|
-
active_revision: activeRevision,
|
|
473
|
-
active_source_hash: activeRevision,
|
|
474
|
-
...status,
|
|
475
|
-
readiness: readinessState.get(),
|
|
476
|
-
runtime: "running",
|
|
477
|
-
version: deployment.version,
|
|
478
|
-
deployment_runtime: deployment.runtime,
|
|
479
|
-
image_variant: deployment.image_variant,
|
|
480
|
-
},
|
|
481
|
-
}),
|
|
482
|
-
{ status: 200, headers },
|
|
483
|
-
);
|
|
484
|
-
}
|
|
588
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
589
|
+
if (allowOriginHeader)
|
|
590
|
+
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
485
591
|
|
|
486
|
-
|
|
487
|
-
|
|
592
|
+
if (
|
|
593
|
+
req.method === "GET" &&
|
|
594
|
+
url.pathname === "/admin/v1/capabilities"
|
|
595
|
+
) {
|
|
596
|
+
const isExternallyManaged =
|
|
597
|
+
process.env.SKILLMUX_CONFIG_READONLY === "true";
|
|
488
598
|
return new Response(
|
|
489
599
|
JSON.stringify({
|
|
490
|
-
|
|
491
|
-
|
|
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,
|
|
492
607
|
}),
|
|
493
|
-
{ status:
|
|
608
|
+
{ status: 200, headers },
|
|
494
609
|
);
|
|
495
610
|
}
|
|
496
611
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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}"`);
|
|
503
622
|
return new Response(
|
|
504
623
|
JSON.stringify({
|
|
505
|
-
|
|
506
|
-
|
|
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
|
+
},
|
|
507
641
|
}),
|
|
508
|
-
{ status:
|
|
642
|
+
{ status: 200, headers },
|
|
509
643
|
);
|
|
510
644
|
}
|
|
511
645
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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,
|
|
519
700
|
});
|
|
520
701
|
}
|
|
521
702
|
|
|
522
|
-
return new Response(
|
|
523
|
-
status: 200,
|
|
524
|
-
headers,
|
|
525
|
-
});
|
|
703
|
+
return new Response("Not Found", { status: 404, headers });
|
|
526
704
|
}
|
|
527
705
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
538
|
-
|
|
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
|
|
539
717
|
}
|
|
540
|
-
|
|
541
|
-
// Non-JSON or parsing error
|
|
542
|
-
}
|
|
543
|
-
metricsRegistry.recordRequest(mcpMethod);
|
|
718
|
+
metricsRegistry.recordRequest(mcpMethod);
|
|
544
719
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
+
});
|
|
552
733
|
}
|
|
553
|
-
return new Response(res.body, {
|
|
554
|
-
status: res.status,
|
|
555
|
-
statusText: res.statusText,
|
|
556
|
-
headers,
|
|
557
|
-
});
|
|
558
734
|
},
|
|
559
735
|
});
|
|
560
736
|
let stopped = false;
|
|
@@ -562,6 +738,7 @@ export async function startServer(opts?: {
|
|
|
562
738
|
console.log(`skillmux serving over HTTP on ${hostname}:${bunServer.port}`);
|
|
563
739
|
return {
|
|
564
740
|
port: bunServer.port,
|
|
741
|
+
statsPort: statsHandle?.port,
|
|
565
742
|
reloadStatus: () =>
|
|
566
743
|
configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
|
|
567
744
|
async stop() {
|
|
@@ -571,6 +748,7 @@ export async function startServer(opts?: {
|
|
|
571
748
|
readinessState.set({ ...readinessState.get(), status: "stopping" });
|
|
572
749
|
metricsRegistry.setReadiness(readinessState.get());
|
|
573
750
|
bunServer.stop(true);
|
|
751
|
+
statsHandle?.stop();
|
|
574
752
|
configWatcher?.stop();
|
|
575
753
|
stopWatcher();
|
|
576
754
|
snapshots.dispose();
|
|
@@ -582,6 +760,7 @@ export async function startServer(opts?: {
|
|
|
582
760
|
await server.connect(new StdioServerTransport());
|
|
583
761
|
let stopped = false;
|
|
584
762
|
return {
|
|
763
|
+
statsPort: statsHandle?.port,
|
|
585
764
|
reloadStatus: () =>
|
|
586
765
|
configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
|
|
587
766
|
async stop() {
|
|
@@ -590,6 +769,7 @@ export async function startServer(opts?: {
|
|
|
590
769
|
clearInterval(auditPruneInterval);
|
|
591
770
|
readinessState.set({ ...readinessState.get(), status: "stopping" });
|
|
592
771
|
metricsRegistry.setReadiness(readinessState.get());
|
|
772
|
+
statsHandle?.stop();
|
|
593
773
|
configWatcher?.stop();
|
|
594
774
|
stopWatcher();
|
|
595
775
|
snapshots.dispose();
|