@klhapp/skillmux 1.9.2 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +1 -1
  3. package/docs/README.md +1 -1
  4. package/docs/cli.md +74 -3
  5. package/docs/concepts.md +1 -1
  6. package/docs/configuration.md +52 -1
  7. package/docs/deployment.md +10 -6
  8. package/docs/getting-started.md +1 -1
  9. package/docs/skill-management.md +49 -0
  10. package/package.json +1 -1
  11. package/src/adapters.ts +148 -2
  12. package/src/cli.ts +302 -1291
  13. package/src/clients.ts +17 -0
  14. package/src/commands/audit.ts +54 -51
  15. package/src/commands/config.ts +11 -12
  16. package/src/commands/context.ts +103 -0
  17. package/src/commands/core.ts +5 -1
  18. package/src/commands/doctor.ts +76 -0
  19. package/src/commands/eval.ts +10 -13
  20. package/src/commands/init.ts +621 -0
  21. package/src/commands/install.ts +132 -0
  22. package/src/commands/local-vault.ts +60 -0
  23. package/src/commands/models.ts +10 -0
  24. package/src/commands/outdated.ts +8 -5
  25. package/src/commands/project.ts +37 -11
  26. package/src/commands/report.ts +66 -0
  27. package/src/commands/scan.ts +61 -0
  28. package/src/commands/skill.ts +32 -0
  29. package/src/commands/sync.ts +232 -0
  30. package/src/commands/target.ts +18 -6
  31. package/src/commands/update.ts +11 -5
  32. package/src/concurrency-limiter.ts +61 -0
  33. package/src/config-service.ts +1 -51
  34. package/src/config.ts +5 -0
  35. package/src/context.ts +8 -3
  36. package/src/db-audit.ts +286 -0
  37. package/src/db-index.ts +238 -0
  38. package/src/db.ts +3 -413
  39. package/src/global-flags.ts +46 -0
  40. package/src/install.ts +15 -0
  41. package/src/logger.ts +26 -0
  42. package/src/output.ts +30 -5
  43. package/src/redact.ts +52 -0
  44. package/src/router-core.ts +8 -27
  45. package/src/server.ts +594 -267
  46. package/src/toml-writer.ts +51 -0
  47. package/src/types.ts +7 -0
package/src/server.ts CHANGED
@@ -16,11 +16,15 @@ import {
16
16
  resolveSkill,
17
17
  } from "./router-core";
18
18
  import { closeRuntime, getRuntime, startVaultWatcher } from "./router-core";
19
- import { getStats, SINCE_PATTERN } from "./stats";
19
+ import { getStats, parseSince, SINCE_PATTERN } from "./stats";
20
+ import { countPrunable, insertAdminAuditRow, pruneAuditBefore, type AdminAuditChange } from "./db";
21
+ import { buildPromotedCases, evalVault, queryPromotableFetches } from "./eval";
20
22
  import { SKILL_ID_PATTERN } from "./vault";
21
23
  import { MetricsRegistry } from "./metrics";
22
24
  import { ReadinessState } from "./readiness";
23
25
  import { initializeRuntime } from "./lifecycle";
26
+ import { buildRedactor } from "./redact";
27
+ import { redactedErrorLog } from "./logger";
24
28
  import type { Clients, Config } from "./types";
25
29
  import {
26
30
  computeHash,
@@ -34,8 +38,14 @@ import {
34
38
  export const metricsRegistry = new MetricsRegistry();
35
39
  export const readinessState = new ReadinessState();
36
40
 
41
+ // runtime-resource-hardening: positive defaults so the http transport is
42
+ // never unbounded by omission, unlike the opt-in [egress] allowlist.
43
+ const DEFAULT_MAX_BODY_BYTES = 1_048_576; // 1 MiB
44
+ const DEFAULT_MAX_CONCURRENT_REQUESTS = 100;
45
+
37
46
  export interface ServerHandle {
38
47
  port?: number;
48
+ statsPort?: number;
39
49
  reloadStatus(): ReloadStatus;
40
50
  stop(): Promise<void>;
41
51
  }
@@ -98,6 +108,100 @@ export function safeTokenEquals(a: string, b: string): boolean {
98
108
  return timingSafeEqual(hashA, hashB);
99
109
  }
100
110
 
111
+ /**
112
+ * A second, narrow HTTP listener exposing only GET /health and GET /stats —
113
+ * nothing from the MCP tool surface or /admin/v1/*. Lets a stdio deployment
114
+ * (no HTTP transport at all) still answer `skillmux report --server ...`
115
+ * remotely, without switching its primary transport. Reuses the exact same
116
+ * bind-posture guard, auth-token check, and rate limiter as the http
117
+ * transport's /stats route (server.ts's main Bun.serve handler) rather than
118
+ * inventing a separate security model for this listener.
119
+ */
120
+ async function serveStatsOnly(opts: {
121
+ config: Config;
122
+ port: number;
123
+ }): Promise<{ port: number | undefined; stop(): void }> {
124
+ const serverConfig = opts.config.server || {
125
+ auth_enabled: false,
126
+ auth_token_env: "SKILLMUX_AUTH_TOKEN",
127
+ allowed_origins: [],
128
+ };
129
+ const hostname = serverConfig.hostname ?? "127.0.0.1";
130
+ assertSafeBindPosture(hostname, serverConfig.auth_enabled ?? false);
131
+
132
+ const { RateLimiter } = await import("./rate-limiter");
133
+ const rateLimiter = new RateLimiter(
134
+ serverConfig.rate_limit || { enabled: false, requests_per_minute: 60 },
135
+ );
136
+
137
+ const bunServer = Bun.serve({
138
+ port: opts.port,
139
+ hostname,
140
+ async fetch(req, server) {
141
+ const rateLimitResult = rateLimiter.check({
142
+ nowMs: Date.now(),
143
+ auth_enabled: serverConfig.auth_enabled,
144
+ req,
145
+ server,
146
+ });
147
+ if (!rateLimitResult.allowed) {
148
+ return new Response("Too Many Requests", {
149
+ status: 429,
150
+ headers: rateLimitResult.headers,
151
+ });
152
+ }
153
+
154
+ const url = new URL(req.url);
155
+ // GET /health stays open (unauthenticated) even when auth_enabled — it
156
+ // carries no data, matching the http transport's /health, which returns
157
+ // before its own Token Auth Check for the same reason.
158
+ if (req.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) {
159
+ return new Response(JSON.stringify({ status: "ok" }), {
160
+ status: 200,
161
+ headers: { "Content-Type": "application/json", ...rateLimitResult.headers },
162
+ });
163
+ }
164
+
165
+ if (serverConfig.auth_enabled) {
166
+ const expectedToken = resolveAuthToken(serverConfig.auth_token_env);
167
+ if (!expectedToken) {
168
+ return new Response(
169
+ "Server authentication configured but token environment variable is empty",
170
+ { status: 500 },
171
+ );
172
+ }
173
+ const authHeader = req.headers.get("authorization") || "";
174
+ const token = authHeader.startsWith("Bearer ")
175
+ ? authHeader.slice(7)
176
+ : authHeader;
177
+ if (!token || !safeTokenEquals(token, expectedToken)) {
178
+ return new Response("Unauthorized", { status: 401 });
179
+ }
180
+ }
181
+
182
+ if (req.method === "GET" && url.pathname === "/stats") {
183
+ const since = url.searchParams.get("since") ?? "";
184
+ if (!SINCE_PATTERN.test(since)) {
185
+ return new Response(
186
+ JSON.stringify({
187
+ error: "since must be a relative window (e.g. 30d) or an absolute ISO-8601 date",
188
+ }),
189
+ { status: 400, headers: { "Content-Type": "application/json" } },
190
+ );
191
+ }
192
+ const { auditDb } = await getRuntime();
193
+ return new Response(JSON.stringify(getStats(auditDb, since)), {
194
+ status: 200,
195
+ headers: { "Content-Type": "application/json", ...rateLimitResult.headers },
196
+ });
197
+ }
198
+ return new Response("Not Found", { status: 404 });
199
+ },
200
+ });
201
+
202
+ return { port: bunServer.port, stop: () => bunServer.stop(true) };
203
+ }
204
+
101
205
  export function createMcpServer(): McpServer {
102
206
  const server = new McpServer({ name: "skillmux", version: "0.1.0" });
103
207
 
@@ -174,12 +278,14 @@ export function createMcpServer(): McpServer {
174
278
  export async function startServer(opts?: {
175
279
  transport?: "stdio" | "http";
176
280
  port?: number;
281
+ statsPort?: number;
177
282
  config?: Config;
178
283
  clients?: Partial<Clients>;
179
284
  configPath?: string;
180
285
  }): Promise<ServerHandle> {
181
286
  const configPath = resolveConfigPath(opts?.configPath);
182
287
  const config = opts?.config ?? (await loadConfig(configPath));
288
+ const redact = buildRedactor(config);
183
289
  const initialClients = { ...createClients(config), ...opts?.clients };
184
290
  const snapshots = RuntimeSnapshotManager.create(config, initialClients);
185
291
  const inactiveReloadStatus: ReloadStatus = {
@@ -204,18 +310,18 @@ export async function startServer(opts?: {
204
310
  configure({ config: nextConfig, clients: nextClients });
205
311
  },
206
312
  onError: (error) =>
207
- console.error("skillmux config reload error:", error),
313
+ console.error(...redactedErrorLog("skillmux config reload error:", error, redact)),
208
314
  })
209
315
  : undefined;
210
316
  const stopWatcher = await startVaultWatcher();
211
317
  const initPromise = initializeRuntime(readinessState)
212
318
  .then(() => metricsRegistry.setReadiness(readinessState.get()))
213
- .catch((err) => console.error("skillmux runtime init error:", err));
319
+ .catch((err) => console.error(...redactedErrorLog("skillmux runtime init error:", err, redact)));
214
320
 
215
321
  // AC14: fire-and-forget so this never delays readiness or blocks a resolve;
216
322
  // not chained onto initPromise, which is awaited below for HTTP transport.
217
323
  const runAuditPrune = () =>
218
- pruneAuditIfDue().catch((err) => console.error("skillmux audit prune error:", err));
324
+ pruneAuditIfDue().catch((err) => console.error(...redactedErrorLog("skillmux audit prune error:", err, redact)));
219
325
  runAuditPrune();
220
326
  const auditPruneInterval = setInterval(runAuditPrune, 24 * 60 * 60 * 1000);
221
327
  auditPruneInterval.unref();
@@ -223,6 +329,16 @@ export async function startServer(opts?: {
223
329
  const server = createMcpServer();
224
330
 
225
331
  const transportType = opts?.transport ?? "stdio";
332
+ if (opts?.statsPort !== undefined && transportType === "http") {
333
+ throw new Error(
334
+ "skillmux: --stats-port is not supported with --transport http; the http transport already serves /stats on --port",
335
+ );
336
+ }
337
+ const statsHandle =
338
+ opts?.statsPort !== undefined
339
+ ? await serveStatsOnly({ config, port: opts.statsPort })
340
+ : undefined;
341
+
226
342
  if (transportType === "http") {
227
343
  const { WebStandardStreamableHTTPServerTransport } =
228
344
  await import("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js");
@@ -235,6 +351,10 @@ export async function startServer(opts?: {
235
351
  const rateLimiter = new RateLimiter(
236
352
  config.server?.rate_limit || { enabled: false, requests_per_minute: 60 },
237
353
  );
354
+ const { ConcurrencyLimiter, releaseOnStreamClose } = await import("./concurrency-limiter");
355
+ const concurrencyLimiter = new ConcurrencyLimiter(
356
+ config.server?.max_concurrent_requests ?? DEFAULT_MAX_CONCURRENT_REQUESTS,
357
+ );
238
358
 
239
359
  const port = opts?.port ?? Number(process.env.PORT || 3000);
240
360
  const hostname = config.server?.hostname ?? "127.0.0.1";
@@ -242,319 +362,522 @@ export async function startServer(opts?: {
242
362
  const bunServer = Bun.serve({
243
363
  port,
244
364
  hostname,
365
+ maxRequestBodySize: config.server?.max_body_bytes ?? DEFAULT_MAX_BODY_BYTES,
245
366
  async fetch(req, server) {
246
- const serverConfig = config.server || {
247
- auth_enabled: false,
248
- auth_token_env: "SKILLMUX_AUTH_TOKEN",
249
- allowed_origins: [],
250
- };
251
- const origin = req.headers.get("origin") || "";
252
- const allowedOrigins = serverConfig.allowed_origins;
253
- const isAllowed =
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 });
367
+ // AC3: a positive bound on in-flight requests, checked before any other
368
+ // work — protects against connection exhaustion the same way
369
+ // maxRequestBodySize protects against a single oversized request.
370
+ if (!concurrencyLimiter.tryAcquire()) {
371
+ return new Response("Service Unavailable", {
372
+ status: 503,
373
+ headers: { "Retry-After": "1" },
374
+ });
263
375
  }
264
-
265
- if (req.method === "OPTIONS") {
266
- return new Response(null, {
267
- headers: {
268
- "Access-Control-Allow-Origin": allowOriginHeader,
269
- "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
270
- "Access-Control-Allow-Headers":
271
- "Content-Type, Authorization, MCP-Protocol-Version",
272
- },
376
+ try {
377
+ const res = await handleHttpRequest(req, server);
378
+ // Defer the release until the response body actually finishes —
379
+ // a buffered body drains almost immediately, but an open SSE
380
+ // stream (the MCP transport's notification channel and streaming
381
+ // replies) can stay open long after this async function returns,
382
+ // and the slot must reflect that real connection lifetime.
383
+ const body = releaseOnStreamClose(res.body, () => concurrencyLimiter.release());
384
+ return new Response(body, {
385
+ status: res.status,
386
+ statusText: res.statusText,
387
+ headers: res.headers,
273
388
  });
389
+ } catch (error) {
390
+ concurrencyLimiter.release();
391
+ throw error;
274
392
  }
275
393
 
276
- // Run rate limiter check
277
- const rateLimitResult = rateLimiter.check({
278
- nowMs: Date.now(),
279
- auth_enabled: serverConfig.auth_enabled,
280
- req,
281
- server,
282
- });
283
-
284
- if (!rateLimitResult.allowed) {
285
- metricsRegistry.recordRateLimitExceeded();
286
-
287
- // Count the request in requests_total under the method if possible
288
- let mcpMethod = "unknown";
289
- try {
290
- const bodyClone = await req.clone().json();
291
- if (bodyClone.method === "tools/call") {
292
- mcpMethod = bodyClone.params?.name || "tools/call";
293
- } else {
294
- mcpMethod = bodyClone.method || "unknown";
295
- }
296
- } catch {
297
- // Non-JSON or parsing error
394
+ async function handleHttpRequest(
395
+ req: Request,
396
+ server: { requestIP(request: Request): { address: string } | null },
397
+ ): Promise<Response> {
398
+ const serverConfig = config.server || {
399
+ auth_enabled: false,
400
+ auth_token_env: "SKILLMUX_AUTH_TOKEN",
401
+ allowed_origins: [],
402
+ };
403
+ const origin = req.headers.get("origin") || "";
404
+ const allowedOrigins = serverConfig.allowed_origins || [];
405
+ const isAllowed =
406
+ allowedOrigins.includes("*") || allowedOrigins.includes(origin);
407
+ const allowOriginHeader = isAllowed
408
+ ? allowedOrigins.includes("*")
409
+ ? "*"
410
+ : origin
411
+ : "";
412
+
413
+ if (origin && !isAllowed) {
414
+ return new Response("CORS origin not allowed", { status: 403 });
298
415
  }
299
- metricsRegistry.recordRequest(mcpMethod);
300
416
 
301
- const headers = new Headers(rateLimitResult.headers);
302
- if (allowOriginHeader) {
303
- headers.set("Access-Control-Allow-Origin", allowOriginHeader);
417
+ if (req.method === "OPTIONS") {
418
+ return new Response(null, {
419
+ headers: {
420
+ "Access-Control-Allow-Origin": allowOriginHeader,
421
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
422
+ "Access-Control-Allow-Headers":
423
+ "Content-Type, Authorization, MCP-Protocol-Version",
424
+ },
425
+ });
304
426
  }
305
- return new Response("Too Many Requests", {
306
- status: 429,
307
- headers,
427
+
428
+ // Run rate limiter check
429
+ const rateLimitResult = rateLimiter.check({
430
+ nowMs: Date.now(),
431
+ auth_enabled: serverConfig.auth_enabled,
432
+ req,
433
+ server,
308
434
  });
309
- }
310
435
 
311
- const url = new URL(req.url);
312
- if (req.method === "GET") {
313
- if (url.pathname === "/health" || url.pathname === "/health/live") {
314
- const headers = new Headers({ "Content-Type": "application/json" });
436
+ if (!rateLimitResult.allowed) {
437
+ metricsRegistry.recordRateLimitExceeded();
438
+
439
+ // Count the request in requests_total under the method if possible
440
+ let mcpMethod = "unknown";
441
+ try {
442
+ const bodyClone = await req.clone().json();
443
+ if (bodyClone.method === "tools/call") {
444
+ mcpMethod = bodyClone.params?.name || "tools/call";
445
+ } else {
446
+ mcpMethod = bodyClone.method || "unknown";
447
+ }
448
+ } catch {
449
+ // Non-JSON or parsing error
450
+ }
451
+ metricsRegistry.recordRequest(mcpMethod);
452
+
453
+ const headers = new Headers(rateLimitResult.headers);
315
454
  if (allowOriginHeader) {
316
455
  headers.set("Access-Control-Allow-Origin", allowOriginHeader);
317
456
  }
318
- for (const [key, value] of Object.entries(
319
- rateLimitResult.headers,
320
- )) {
321
- headers.set(key, value);
322
- }
323
- return new Response(JSON.stringify({ status: "ok" }), {
324
- status: 200,
457
+ return new Response("Too Many Requests", {
458
+ status: 429,
325
459
  headers,
326
460
  });
327
461
  }
328
- if (url.pathname === "/health/ready") {
329
- const readiness = readinessState.get();
330
- const deployment = describeDeployment(config);
462
+
463
+ const url = new URL(req.url);
464
+ if (req.method === "GET") {
465
+ if (url.pathname === "/health" || url.pathname === "/health/live") {
466
+ const headers = new Headers({ "Content-Type": "application/json" });
467
+ if (allowOriginHeader) {
468
+ headers.set("Access-Control-Allow-Origin", allowOriginHeader);
469
+ }
470
+ for (const [key, value] of Object.entries(
471
+ rateLimitResult.headers,
472
+ )) {
473
+ headers.set(key, value);
474
+ }
475
+ return new Response(JSON.stringify({ status: "ok" }), {
476
+ status: 200,
477
+ headers,
478
+ });
479
+ }
480
+ if (url.pathname === "/health/ready") {
481
+ const readiness = readinessState.get();
482
+ const deployment = describeDeployment(config);
483
+ const headers = new Headers({ "Content-Type": "application/json" });
484
+ if (allowOriginHeader)
485
+ headers.set("Access-Control-Allow-Origin", allowOriginHeader);
486
+ return new Response(JSON.stringify({
487
+ ...readiness,
488
+ version: deployment.version,
489
+ runtime: deployment.runtime,
490
+ image_variant: deployment.image_variant,
491
+ }), {
492
+ status: readiness.status === "ready" ? 200 : 503,
493
+ headers,
494
+ });
495
+ }
496
+ if (url.pathname === "/metrics") {
497
+ const headers = new Headers({
498
+ "Content-Type": "text/plain; version=0.0.4",
499
+ });
500
+ if (allowOriginHeader) {
501
+ headers.set("Access-Control-Allow-Origin", allowOriginHeader);
502
+ }
503
+ for (const [key, value] of Object.entries(
504
+ rateLimitResult.headers,
505
+ )) {
506
+ headers.set(key, value);
507
+ }
508
+ return new Response(metricsRegistry.render(), {
509
+ status: 200,
510
+ headers,
511
+ });
512
+ }
513
+ }
514
+
515
+ // Token Auth Check
516
+ if (serverConfig.auth_enabled) {
517
+ const expectedToken = resolveAuthToken(serverConfig.auth_token_env);
518
+ if (!expectedToken) {
519
+ return new Response(
520
+ "Server authentication configured but token environment variable is empty",
521
+ { status: 500 },
522
+ );
523
+ }
524
+ const authHeader = req.headers.get("authorization") || "";
525
+ const token = authHeader.startsWith("Bearer ")
526
+ ? authHeader.slice(7)
527
+ : authHeader;
528
+ if (!token || !safeTokenEquals(token, expectedToken)) {
529
+ return new Response("Unauthorized", { status: 401 });
530
+ }
531
+ }
532
+
533
+ // GET /stats — placed after the Token Auth Check above (unlike /health and /metrics,
534
+ // which return earlier and stay open) since audit queries carry raw user text.
535
+ if (req.method === "GET" && url.pathname === "/stats") {
536
+ const since = url.searchParams.get("since") ?? "";
537
+ if (!SINCE_PATTERN.test(since)) {
538
+ return new Response(
539
+ JSON.stringify({
540
+ error:
541
+ "since must be a relative window (e.g. 30d) or an absolute ISO-8601 date",
542
+ }),
543
+ { status: 400, headers: { "Content-Type": "application/json" } },
544
+ );
545
+ }
546
+ const { auditDb } = await getRuntime();
331
547
  const headers = new Headers({ "Content-Type": "application/json" });
332
548
  if (allowOriginHeader)
333
549
  headers.set("Access-Control-Allow-Origin", allowOriginHeader);
334
- return new Response(JSON.stringify({
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
- )) {
550
+ for (const [key, value] of Object.entries(rateLimitResult.headers))
354
551
  headers.set(key, value);
355
- }
356
- return new Response(metricsRegistry.render(), {
552
+ return new Response(JSON.stringify(getStats(auditDb, since)), {
357
553
  status: 200,
358
554
  headers,
359
555
  });
360
556
  }
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
557
 
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
-
412
- const adminTokenEnv =
413
- serverConfig.admin.token_env || "SKILLMUX_ADMIN_TOKEN";
414
- const expectedAdminToken = process.env[adminTokenEnv] || "";
415
- const authHeader = req.headers.get("authorization") || "";
416
- const token = authHeader.startsWith("Bearer ")
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
- }
558
+ // Admin HTTP API (/admin/v1/*)
559
+ if (url.pathname.startsWith("/admin/v1/")) {
560
+ if (!serverConfig.admin?.enabled) {
561
+ return new Response("Admin endpoints disabled", { status: 403 });
562
+ }
427
563
 
428
- const headers = new Headers({ "Content-Type": "application/json" });
429
- if (allowOriginHeader)
430
- headers.set("Access-Control-Allow-Origin", allowOriginHeader);
564
+ const adminTokenEnv =
565
+ serverConfig.admin.token_env || "SKILLMUX_ADMIN_TOKEN";
566
+ const expectedAdminToken = process.env[adminTokenEnv] || "";
567
+ const authHeader = req.headers.get("authorization") || "";
568
+ const token = authHeader.startsWith("Bearer ")
569
+ ? authHeader.slice(7)
570
+ : authHeader;
571
+
572
+ if (
573
+ !expectedAdminToken ||
574
+ !token ||
575
+ !safeTokenEquals(token, expectedAdminToken)
576
+ ) {
577
+ return new Response("Unauthorized", { status: 401 });
578
+ }
431
579
 
432
- if (
433
- req.method === "GET" &&
434
- url.pathname === "/admin/v1/capabilities"
435
- ) {
436
- const isExternallyManaged =
437
- process.env.SKILLMUX_CONFIG_READONLY === "true";
438
- return new Response(
439
- JSON.stringify({
440
- config_read: true,
441
- config_write: !isExternallyManaged,
442
- persistence: isExternallyManaged
443
- ? "externally_managed"
444
- : "writable",
445
- reloadable_keys: RELOADABLE_KEYS,
446
- restart_required_keys: RESTART_REQUIRED_KEYS,
447
- }),
448
- { status: 200, headers },
449
- );
450
- }
580
+ const headers = new Headers({ "Content-Type": "application/json" });
581
+ if (allowOriginHeader)
582
+ headers.set("Access-Control-Allow-Origin", allowOriginHeader);
451
583
 
452
- if (req.method === "GET" && url.pathname === "/admin/v1/config") {
453
- const { effective, sources } = await getEffectiveConfig(configPath);
454
- const deployment = describeDeployment(config);
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
- }
584
+ if (
585
+ req.method === "GET" &&
586
+ url.pathname === "/admin/v1/capabilities"
587
+ ) {
588
+ const isExternallyManaged =
589
+ process.env.SKILLMUX_CONFIG_READONLY === "true";
590
+ return new Response(
591
+ JSON.stringify({
592
+ config_read: true,
593
+ config_write: !isExternallyManaged,
594
+ persistence: isExternallyManaged
595
+ ? "externally_managed"
596
+ : "writable",
597
+ reloadable_keys: RELOADABLE_KEYS,
598
+ restart_required_keys: RESTART_REQUIRED_KEYS,
599
+ }),
600
+ { status: 200, headers },
601
+ );
602
+ }
485
603
 
486
- if (req.method === "PATCH" && url.pathname === "/admin/v1/config") {
487
- if (process.env.SKILLMUX_CONFIG_READONLY === "true") {
604
+ if (req.method === "GET" && url.pathname === "/admin/v1/config") {
605
+ const { effective, sources } = await getEffectiveConfig(configPath);
606
+ const deployment = describeDeployment(config);
607
+ const desiredHash = computeHash(effective);
608
+ const snapshot = snapshots.acquire();
609
+ const activeRevision = computeHash(snapshot.snapshot.config);
610
+ snapshot.release();
611
+ const status =
612
+ configWatcher?.reloadStatus() ?? inactiveReloadStatus;
613
+ headers.set("ETag", `"${desiredHash}"`);
488
614
  return new Response(
489
615
  JSON.stringify({
490
- error: "CONFIG_EXTERNALLY_MANAGED",
491
- message: "Configuration is externally managed",
616
+ desired: effective,
617
+ effective,
618
+ sources,
619
+ active_revision: activeRevision,
620
+ runtime: {
621
+ target: "local",
622
+ desired_source: configPath,
623
+ desired_source_hash: desiredHash,
624
+ active_revision: activeRevision,
625
+ active_source_hash: activeRevision,
626
+ ...status,
627
+ readiness: readinessState.get(),
628
+ runtime: "running",
629
+ version: deployment.version,
630
+ deployment_runtime: deployment.runtime,
631
+ image_variant: deployment.image_variant,
632
+ },
492
633
  }),
493
- { status: 409, headers },
634
+ { status: 200, headers },
494
635
  );
495
636
  }
496
637
 
497
- const ifMatch = req.headers.get("if-match") || "";
498
- const cleanIfMatch = ifMatch.replace(/^"|"$/g, "");
499
- const { effective } = await getEffectiveConfig(configPath);
500
- const currentHash = computeHash(effective);
638
+ if (req.method === "PATCH" && url.pathname === "/admin/v1/config") {
639
+ if (process.env.SKILLMUX_CONFIG_READONLY === "true") {
640
+ return new Response(
641
+ JSON.stringify({
642
+ error: "CONFIG_EXTERNALLY_MANAGED",
643
+ message: "Configuration is externally managed",
644
+ }),
645
+ { status: 409, headers },
646
+ );
647
+ }
648
+
649
+ const ifMatch = req.headers.get("if-match") || "";
650
+ const cleanIfMatch = ifMatch.replace(/^"|"$/g, "");
651
+ const { effective } = await getEffectiveConfig(configPath);
652
+ const currentHash = computeHash(effective);
653
+
654
+ if (!ifMatch || cleanIfMatch !== currentHash) {
655
+ return new Response(
656
+ JSON.stringify({
657
+ error: "CONFIG_REVISION_CONFLICT",
658
+ message: "Revision conflict",
659
+ }),
660
+ { status: 409, headers },
661
+ );
662
+ }
663
+
664
+ const body = (await req.json()) as {
665
+ changes: Record<string, string | number | boolean>;
666
+ };
667
+ let lastResult: any = null;
668
+ const auditChanges: AdminAuditChange[] = [];
669
+ for (const [k, v] of Object.entries(body.changes ?? {})) {
670
+ lastResult = await setDottedKey(k, String(v), {
671
+ targetName: "remote",
672
+ });
673
+ auditChanges.push({
674
+ key: k,
675
+ old_value: lastResult.prior_val,
676
+ new_value: lastResult.resulting_val,
677
+ });
678
+ }
679
+
680
+ if (auditChanges.length > 0 && lastResult) {
681
+ const { auditDb } = await getRuntime();
682
+ insertAdminAuditRow(auditDb, {
683
+ ts: new Date().toISOString(),
684
+ changes: auditChanges,
685
+ resulting_revision: lastResult.resulting_revision,
686
+ });
687
+ }
688
+
689
+ return new Response(JSON.stringify(lastResult ?? { ok: true }), {
690
+ status: 200,
691
+ headers,
692
+ });
693
+ }
501
694
 
502
- if (!ifMatch || cleanIfMatch !== currentHash) {
695
+ if (
696
+ req.method === "POST" &&
697
+ url.pathname === "/admin/v1/audit/prune"
698
+ ) {
699
+ let body: {
700
+ older_than?: string;
701
+ dry_run?: boolean;
702
+ confirm?: boolean;
703
+ } = {};
704
+ try {
705
+ const text = await req.text();
706
+ if (text.trim()) {
707
+ body = JSON.parse(text);
708
+ }
709
+ } catch {
710
+ return new Response(
711
+ JSON.stringify({
712
+ error: "INVALID_JSON",
713
+ message: "Request body must be valid JSON",
714
+ }),
715
+ { status: 400, headers },
716
+ );
717
+ }
718
+
719
+ const dryRun = body.dry_run ?? false;
720
+ const confirm = body.confirm ?? false;
721
+ if (!dryRun && !confirm) {
722
+ return new Response(
723
+ JSON.stringify({
724
+ error: "CONFIRMATION_REQUIRED",
725
+ message:
726
+ "Non-dry-run audit prune requires confirm: true",
727
+ }),
728
+ { status: 400, headers },
729
+ );
730
+ }
731
+
732
+ const { effective } = await getEffectiveConfig(configPath);
733
+ let cutoff: Date;
734
+ if (body.older_than) {
735
+ try {
736
+ cutoff = parseSince(body.older_than);
737
+ } catch (err: any) {
738
+ return new Response(
739
+ JSON.stringify({
740
+ error: "INVALID_CUTOFF",
741
+ message: err.message,
742
+ }),
743
+ { status: 400, headers },
744
+ );
745
+ }
746
+ } else {
747
+ const retentionDays = effective.audit?.retention_days ?? 90;
748
+ if (retentionDays <= 0) {
749
+ return new Response(
750
+ JSON.stringify({
751
+ audit_deleted: 0,
752
+ fetch_deleted: 0,
753
+ admin_audit_deleted: 0,
754
+ dry_run: dryRun,
755
+ cutoff: null,
756
+ }),
757
+ { status: 200, headers },
758
+ );
759
+ }
760
+ cutoff = new Date(Date.now() - retentionDays * 86_400_000);
761
+ }
762
+ const cutoffIso = cutoff.toISOString();
763
+
764
+ const { auditDb } = await getRuntime();
765
+ if (dryRun) {
766
+ const counts = countPrunable(auditDb, cutoffIso);
767
+ return new Response(
768
+ JSON.stringify({
769
+ ...counts,
770
+ dry_run: true,
771
+ cutoff: cutoffIso,
772
+ }),
773
+ { status: 200, headers },
774
+ );
775
+ }
776
+
777
+ const counts = pruneAuditBefore(auditDb, cutoffIso);
503
778
  return new Response(
504
779
  JSON.stringify({
505
- error: "CONFIG_REVISION_CONFLICT",
506
- message: "Revision conflict",
780
+ ...counts,
781
+ dry_run: false,
782
+ cutoff: cutoffIso,
507
783
  }),
508
- { status: 409, headers },
784
+ { status: 200, headers },
509
785
  );
510
786
  }
511
787
 
512
- const body = (await req.json()) as {
513
- changes: Record<string, string | number | boolean>;
514
- };
515
- let lastResult: any = null;
516
- for (const [k, v] of Object.entries(body.changes ?? {})) {
517
- lastResult = await setDottedKey(k, String(v), {
518
- targetName: "remote",
788
+ if (req.method === "POST" && url.pathname === "/admin/v1/eval") {
789
+ const report = await evalVault();
790
+ return new Response(JSON.stringify(report), {
791
+ status: 200,
792
+ headers,
519
793
  });
520
794
  }
521
795
 
522
- return new Response(JSON.stringify(lastResult ?? { ok: true }), {
523
- status: 200,
524
- headers,
525
- });
526
- }
796
+ if (
797
+ req.method === "POST" &&
798
+ url.pathname === "/admin/v1/eval/promote"
799
+ ) {
800
+ let body: { since?: string } = {};
801
+ try {
802
+ const text = await req.text();
803
+ if (text.trim()) {
804
+ body = JSON.parse(text);
805
+ }
806
+ } catch {
807
+ return new Response(
808
+ JSON.stringify({
809
+ error: "INVALID_JSON",
810
+ message: "Request body must be valid JSON",
811
+ }),
812
+ { status: 400, headers },
813
+ );
814
+ }
815
+
816
+ if (!body.since || typeof body.since !== "string") {
817
+ return new Response(
818
+ JSON.stringify({
819
+ error: "MISSING_SINCE",
820
+ message: "Field 'since' is required",
821
+ }),
822
+ { status: 400, headers },
823
+ );
824
+ }
825
+
826
+ let sinceDate: Date;
827
+ try {
828
+ sinceDate = parseSince(body.since);
829
+ } catch (err: any) {
830
+ return new Response(
831
+ JSON.stringify({
832
+ error: "INVALID_SINCE",
833
+ message: err.message,
834
+ }),
835
+ { status: 400, headers },
836
+ );
837
+ }
838
+ const sinceIso = sinceDate.toISOString();
839
+
840
+ const { auditDb } = await getRuntime();
841
+ const candidates = buildPromotedCases(
842
+ queryPromotableFetches(auditDb, sinceIso),
843
+ );
844
+ return new Response(JSON.stringify({ candidates }), {
845
+ status: 200,
846
+ headers,
847
+ });
848
+ }
527
849
 
528
- return new Response("Not Found", { status: 404, headers });
529
- }
850
+ return new Response("Not Found", { status: 404, headers });
851
+ }
530
852
 
531
- // Record request metrics
532
- let mcpMethod = "unknown";
533
- try {
534
- const bodyClone = await req.clone().json();
535
- if (bodyClone.method === "tools/call") {
536
- mcpMethod = bodyClone.params?.name || "tools/call";
537
- } else {
538
- mcpMethod = bodyClone.method || "unknown";
853
+ // Record request metrics
854
+ let mcpMethod = "unknown";
855
+ try {
856
+ const bodyClone = await req.clone().json();
857
+ if (bodyClone.method === "tools/call") {
858
+ mcpMethod = bodyClone.params?.name || "tools/call";
859
+ } else {
860
+ mcpMethod = bodyClone.method || "unknown";
861
+ }
862
+ } catch {
863
+ // Non-JSON or parsing error
539
864
  }
540
- } catch {
541
- // Non-JSON or parsing error
542
- }
543
- metricsRegistry.recordRequest(mcpMethod);
865
+ metricsRegistry.recordRequest(mcpMethod);
544
866
 
545
- const res = await transport.handleRequest(req);
546
- const headers = new Headers(res.headers);
547
- if (allowOriginHeader) {
548
- headers.set("Access-Control-Allow-Origin", allowOriginHeader);
549
- }
550
- for (const [key, value] of Object.entries(rateLimitResult.headers)) {
551
- headers.set(key, value);
867
+ const res = await transport.handleRequest(req);
868
+ const headers = new Headers(res.headers);
869
+ if (allowOriginHeader) {
870
+ headers.set("Access-Control-Allow-Origin", allowOriginHeader);
871
+ }
872
+ for (const [key, value] of Object.entries(rateLimitResult.headers)) {
873
+ headers.set(key, value);
874
+ }
875
+ return new Response(res.body, {
876
+ status: res.status,
877
+ statusText: res.statusText,
878
+ headers,
879
+ });
552
880
  }
553
- return new Response(res.body, {
554
- status: res.status,
555
- statusText: res.statusText,
556
- headers,
557
- });
558
881
  },
559
882
  });
560
883
  let stopped = false;
@@ -562,6 +885,7 @@ export async function startServer(opts?: {
562
885
  console.log(`skillmux serving over HTTP on ${hostname}:${bunServer.port}`);
563
886
  return {
564
887
  port: bunServer.port,
888
+ statsPort: statsHandle?.port,
565
889
  reloadStatus: () =>
566
890
  configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
567
891
  async stop() {
@@ -571,6 +895,7 @@ export async function startServer(opts?: {
571
895
  readinessState.set({ ...readinessState.get(), status: "stopping" });
572
896
  metricsRegistry.setReadiness(readinessState.get());
573
897
  bunServer.stop(true);
898
+ statsHandle?.stop();
574
899
  configWatcher?.stop();
575
900
  stopWatcher();
576
901
  snapshots.dispose();
@@ -582,6 +907,7 @@ export async function startServer(opts?: {
582
907
  await server.connect(new StdioServerTransport());
583
908
  let stopped = false;
584
909
  return {
910
+ statsPort: statsHandle?.port,
585
911
  reloadStatus: () =>
586
912
  configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
587
913
  async stop() {
@@ -590,6 +916,7 @@ export async function startServer(opts?: {
590
916
  clearInterval(auditPruneInterval);
591
917
  readinessState.set({ ...readinessState.get(), status: "stopping" });
592
918
  metricsRegistry.setReadiness(readinessState.get());
919
+ statsHandle?.stop();
593
920
  configWatcher?.stop();
594
921
  stopWatcher();
595
922
  snapshots.dispose();