@klhapp/skillmux 1.11.2 → 1.13.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.
- package/CHANGELOG.md +15 -0
- package/README.md +20 -10
- package/bin/skillmux.js +89 -0
- package/docs/README.md +4 -4
- package/docs/cli.md +15 -5
- package/docs/concepts.md +4 -3
- package/docs/configuration.md +22 -5
- package/docs/deployment.md +3 -3
- package/docs/getting-started.md +32 -7
- package/docs/releasing.md +17 -2
- package/docs/sdd/cli-remote-target-parity/schema-bucket-b.yaml +297 -0
- package/docs/sdd/cli-remote-target-parity/spec-bucket-b.md +95 -0
- package/docs/sdd/cli-remote-target-parity/spec.md +111 -0
- package/docs/sdd/cli-remote-target-parity/think.md +183 -0
- package/docs/sdd/cli-surface-consistency/think.md +149 -0
- package/docs/sdd/config-authority-rerank-resilience/plan.md +338 -0
- package/docs/sdd/config-authority-rerank-resilience/review.md +124 -0
- package/docs/sdd/config-authority-rerank-resilience/spec.md +78 -0
- package/docs/sdd/feat-packaging-deployment/review.md +95 -0
- package/docs/sdd/fetch-outcome-flywheel/review.md +103 -0
- package/docs/sdd/fetch-outcome-flywheel/schema.json +347 -0
- package/docs/sdd/fetch-outcome-flywheel/spec.md +185 -0
- package/docs/sdd/npm-native-binaries/review.md +103 -0
- package/docs/sdd/npm-native-binaries/spec.md +148 -0
- package/docs/sdd/ranked-shortlist-2/pr3-calibration-removal.md +56 -0
- package/docs/sdd/refactor-builtin-target-paths/spec.md +41 -0
- package/docs/sdd/runtime-resource-hardening/review.md +86 -0
- package/docs/sdd/runtime-resource-hardening/spec.md +43 -0
- package/docs/sdd/security-hardening-phase/think.md +129 -0
- package/docs/sdd/security-observability/review.md +96 -0
- package/docs/sdd/security-observability/spec.md +55 -0
- package/docs/sdd/skill-provenance-update/review.md +152 -0
- package/docs/sdd/skill-provenance-update/schema.json +193 -0
- package/docs/sdd/skill-provenance-update/spec.md +134 -0
- package/docs/sdd/supply-chain-hardening/review.md +50 -0
- package/docs/sdd/supply-chain-hardening/spec.md +42 -0
- package/docs/sdd/target-marker-rehome/spec.md +33 -0
- package/docs/skill-management.md +1 -1
- package/docs/troubleshooting.md +18 -2
- package/package.json +15 -8
- package/src/adapters.ts +0 -438
- package/src/audit.ts +0 -21
- package/src/cli.ts +0 -928
- package/src/clients.ts +0 -368
- package/src/commands/audit.ts +0 -85
- package/src/commands/config.ts +0 -224
- package/src/commands/context.ts +0 -104
- package/src/commands/core.ts +0 -56
- package/src/commands/doctor.ts +0 -97
- package/src/commands/eval.ts +0 -88
- package/src/commands/init.ts +0 -672
- package/src/commands/install.ts +0 -155
- package/src/commands/local-vault.ts +0 -60
- package/src/commands/models.ts +0 -10
- package/src/commands/outdated.ts +0 -115
- package/src/commands/project.ts +0 -580
- package/src/commands/report.ts +0 -66
- package/src/commands/scan.ts +0 -71
- package/src/commands/shared.ts +0 -38
- package/src/commands/skill.ts +0 -33
- package/src/commands/sync.ts +0 -233
- package/src/commands/target.ts +0 -225
- package/src/commands/update.ts +0 -277
- package/src/completions.ts +0 -196
- package/src/concurrency-limiter.ts +0 -61
- package/src/config-service.ts +0 -398
- package/src/config-watcher.ts +0 -253
- package/src/config.ts +0 -583
- package/src/context.ts +0 -188
- package/src/db-audit.ts +0 -286
- package/src/db-index.ts +0 -238
- package/src/db.ts +0 -3
- package/src/deployment.ts +0 -39
- package/src/doctor.ts +0 -205
- package/src/eval.ts +0 -302
- package/src/global-flags.ts +0 -46
- package/src/init-agents.ts +0 -329
- package/src/init-instructions.ts +0 -192
- package/src/init.ts +0 -389
- package/src/install.ts +0 -232
- package/src/lifecycle.ts +0 -51
- package/src/logger.ts +0 -26
- package/src/manifest.ts +0 -352
- package/src/mcp-registration.ts +0 -89
- package/src/metrics.ts +0 -121
- package/src/models.ts +0 -20
- package/src/output.ts +0 -214
- package/src/project-setup.ts +0 -36
- package/src/prompts.ts +0 -124
- package/src/provenance.ts +0 -99
- package/src/rate-limiter.ts +0 -137
- package/src/readiness.ts +0 -30
- package/src/redact.ts +0 -52
- package/src/router-core.ts +0 -666
- package/src/rrf.ts +0 -31
- package/src/scan.ts +0 -300
- package/src/server.ts +0 -930
- package/src/setup.ts +0 -145
- package/src/snapshot.ts +0 -135
- package/src/stats.ts +0 -340
- package/src/sync.ts +0 -482
- package/src/toml-writer.ts +0 -51
- package/src/types.ts +0 -212
- package/src/vault.ts +0 -207
package/src/server.ts
DELETED
|
@@ -1,930 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import { createClients } from "./clients";
|
|
7
|
-
import { isLoopbackBindHost, loadConfig, resolveConfigPath } from "./config";
|
|
8
|
-
import { describeDeployment } from "./deployment";
|
|
9
|
-
import { ConfigWatcher, type ReloadStatus } from "./config-watcher";
|
|
10
|
-
import { RuntimeSnapshotManager } from "./snapshot";
|
|
11
|
-
import {
|
|
12
|
-
backfillEmbeddings,
|
|
13
|
-
configure,
|
|
14
|
-
fetchSkill,
|
|
15
|
-
pruneAuditIfDue,
|
|
16
|
-
resolveSkill,
|
|
17
|
-
} from "./router-core";
|
|
18
|
-
import { closeRuntime, getRuntime, startVaultWatcher } from "./router-core";
|
|
19
|
-
import { getStats, parseSince, SINCE_PATTERN } from "./stats";
|
|
20
|
-
import { countPrunable, insertAdminAuditRow, pruneAuditBefore, type AdminAuditChange } from "./db";
|
|
21
|
-
import { buildPromotedCases, evalVault, queryPromotableFetches } from "./eval";
|
|
22
|
-
import { SKILL_ID_PATTERN } from "./vault";
|
|
23
|
-
import { MetricsRegistry } from "./metrics";
|
|
24
|
-
import { ReadinessState } from "./readiness";
|
|
25
|
-
import { initializeRuntime } from "./lifecycle";
|
|
26
|
-
import { buildRedactor } from "./redact";
|
|
27
|
-
import { redactedErrorLog } from "./logger";
|
|
28
|
-
import type { Clients, Config } from "./types";
|
|
29
|
-
import {
|
|
30
|
-
computeHash,
|
|
31
|
-
getEffectiveConfig,
|
|
32
|
-
getLocalConfigStatus,
|
|
33
|
-
setDottedKey,
|
|
34
|
-
RELOADABLE_KEYS,
|
|
35
|
-
RESTART_REQUIRED_KEYS,
|
|
36
|
-
} from "./config-service";
|
|
37
|
-
|
|
38
|
-
export const metricsRegistry = new MetricsRegistry();
|
|
39
|
-
export const readinessState = new ReadinessState();
|
|
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
|
-
|
|
46
|
-
export interface ServerHandle {
|
|
47
|
-
port?: number;
|
|
48
|
-
statsPort?: number;
|
|
49
|
-
reloadStatus(): ReloadStatus;
|
|
50
|
-
stop(): Promise<void>;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Docker's documented deployment mode auto-switches hostname to "0.0.0.0"
|
|
55
|
-
* (config.ts's RUNNING_IN_DOCKER override) while server.auth_enabled still
|
|
56
|
-
* defaults to false and allowed_origins defaults to [] — which only blocks
|
|
57
|
-
* requests that carry an Origin header, so a plain server-to-server/curl
|
|
58
|
-
* request sails through unauthenticated. That default combination leaves MCP
|
|
59
|
-
* tools (resolve_skill/fetch_skill) and /stats (raw historical query text)
|
|
60
|
-
* open to anyone who can reach the port — the documented common case, not an
|
|
61
|
-
* edge case (SMX-91). Refuse to start rather than silently exposing it;
|
|
62
|
-
* SKILLMUX_ALLOW_INSECURE_BIND is the explicit, logged escape hatch for
|
|
63
|
-
* operators who rely on network-level isolation instead of application auth.
|
|
64
|
-
*/
|
|
65
|
-
export function assertSafeBindPosture(
|
|
66
|
-
hostname: string,
|
|
67
|
-
authEnabled: boolean,
|
|
68
|
-
env: Record<string, string | undefined> = process.env,
|
|
69
|
-
): void {
|
|
70
|
-
if (isLoopbackBindHost(hostname) || authEnabled) return;
|
|
71
|
-
const message =
|
|
72
|
-
`refusing to bind "${hostname}" (reachable beyond this machine) with server.auth_enabled=false — ` +
|
|
73
|
-
"MCP tools (resolve_skill/fetch_skill) and /stats would be open to anyone who can reach this port. " +
|
|
74
|
-
"Set server.auth_enabled=true (with SKILLMUX_AUTH_TOKEN) or bind a loopback hostname instead. " +
|
|
75
|
-
"To start anyway — e.g. when network isolation is the intended boundary — set SKILLMUX_ALLOW_INSECURE_BIND=true.";
|
|
76
|
-
if (env.SKILLMUX_ALLOW_INSECURE_BIND !== "true") {
|
|
77
|
-
throw new Error(`skillmux: ${message}`);
|
|
78
|
-
}
|
|
79
|
-
console.error(`skillmux: WARNING — ${message}`);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
let warnedAuthToken = false;
|
|
83
|
-
function resolveAuthToken(envName: string): string {
|
|
84
|
-
const value = process.env[envName];
|
|
85
|
-
if (value) return value;
|
|
86
|
-
if (
|
|
87
|
-
envName === "SKILLMUX_AUTH_TOKEN" &&
|
|
88
|
-
process.env.SKILL_ROUTER_AUTH_TOKEN
|
|
89
|
-
) {
|
|
90
|
-
if (!warnedAuthToken) {
|
|
91
|
-
warnedAuthToken = true;
|
|
92
|
-
console.error(
|
|
93
|
-
"skillmux: SKILL_ROUTER_AUTH_TOKEN is deprecated, set SKILLMUX_AUTH_TOKEN instead",
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
return process.env.SKILL_ROUTER_AUTH_TOKEN;
|
|
97
|
-
}
|
|
98
|
-
return "";
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// SMX-94: comparing raw buffers made length itself observable — a
|
|
102
|
-
// mismatched-length pair returns before ever reaching timingSafeEqual.
|
|
103
|
-
// Hashing both sides to a fixed 32-byte digest first means every
|
|
104
|
-
// comparison takes the same constant-time path regardless of input length.
|
|
105
|
-
export function safeTokenEquals(a: string, b: string): boolean {
|
|
106
|
-
const hashA = createHash("sha256").update(a).digest();
|
|
107
|
-
const hashB = createHash("sha256").update(b).digest();
|
|
108
|
-
return timingSafeEqual(hashA, hashB);
|
|
109
|
-
}
|
|
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
|
-
|
|
205
|
-
export function createMcpServer(): McpServer {
|
|
206
|
-
const server = new McpServer({ name: "skillmux", version: "0.1.0" });
|
|
207
|
-
|
|
208
|
-
// Transport rule from schema.json: the SKILL.md body appears exactly once on
|
|
209
|
-
// the wire — verbatim as text content; structuredContent carries the metadata.
|
|
210
|
-
server.registerTool(
|
|
211
|
-
"resolve_skill",
|
|
212
|
-
{
|
|
213
|
-
description:
|
|
214
|
-
"Route a natural-language task description to candidate skills in the vault. " +
|
|
215
|
-
"Returns a ranked shortlist of candidates. Use fetch_skill to retrieve a candidate's complete instructions.",
|
|
216
|
-
inputSchema: {
|
|
217
|
-
query: z.string().min(1).describe("Natural-language task or prompt description to route."),
|
|
218
|
-
top_k: z
|
|
219
|
-
.number()
|
|
220
|
-
.int()
|
|
221
|
-
.min(1)
|
|
222
|
-
.optional()
|
|
223
|
-
.describe("Optional maximum number of ranked candidates to return (subject to server max_top_k limit)."),
|
|
224
|
-
},
|
|
225
|
-
},
|
|
226
|
-
async ({ query, top_k }) => {
|
|
227
|
-
const startTime = performance.now();
|
|
228
|
-
try {
|
|
229
|
-
const result = await resolveSkill({ query, top_k });
|
|
230
|
-
const duration = (performance.now() - startTime) / 1000;
|
|
231
|
-
metricsRegistry.recordResolveLatencySeconds(duration);
|
|
232
|
-
if (result.degradation_reason) {
|
|
233
|
-
const stage = result.degradation_reason.startsWith("embedding_") ? "embedding" : "reranker";
|
|
234
|
-
metricsRegistry.recordDegradation(stage, result.degradation_reason);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return {
|
|
238
|
-
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
239
|
-
structuredContent: { ...result },
|
|
240
|
-
};
|
|
241
|
-
} catch (err) {
|
|
242
|
-
metricsRegistry.recordError();
|
|
243
|
-
throw err;
|
|
244
|
-
}
|
|
245
|
-
},
|
|
246
|
-
);
|
|
247
|
-
|
|
248
|
-
server.registerTool(
|
|
249
|
-
"fetch_skill",
|
|
250
|
-
{
|
|
251
|
-
description:
|
|
252
|
-
"Fetch a skill's SKILL.md verbatim by skill_id, with sha256 and supporting-file paths. " +
|
|
253
|
-
"Independent of any prior resolve_skill outcome. Pass the request_id from a prior " +
|
|
254
|
-
"resolve_skill call to link this fetch to it for quality measurement.",
|
|
255
|
-
inputSchema: {
|
|
256
|
-
skill_id: z.string().regex(SKILL_ID_PATTERN),
|
|
257
|
-
request_id: z.string().min(1).max(128).optional(),
|
|
258
|
-
},
|
|
259
|
-
},
|
|
260
|
-
async ({ skill_id, request_id }) => {
|
|
261
|
-
try {
|
|
262
|
-
const result = await fetchSkill({ skill_id, request_id });
|
|
263
|
-
const { body, ...meta } = result;
|
|
264
|
-
return {
|
|
265
|
-
content: [{ type: "text" as const, text: body }],
|
|
266
|
-
structuredContent: { ...meta },
|
|
267
|
-
};
|
|
268
|
-
} catch (err) {
|
|
269
|
-
metricsRegistry.recordError();
|
|
270
|
-
throw err;
|
|
271
|
-
}
|
|
272
|
-
},
|
|
273
|
-
);
|
|
274
|
-
|
|
275
|
-
return server;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
export async function startServer(opts?: {
|
|
279
|
-
transport?: "stdio" | "http";
|
|
280
|
-
port?: number;
|
|
281
|
-
statsPort?: number;
|
|
282
|
-
config?: Config;
|
|
283
|
-
clients?: Partial<Clients>;
|
|
284
|
-
configPath?: string;
|
|
285
|
-
}): Promise<ServerHandle> {
|
|
286
|
-
const configPath = resolveConfigPath(opts?.configPath);
|
|
287
|
-
const config = opts?.config ?? (await loadConfig(configPath));
|
|
288
|
-
const redact = buildRedactor(config);
|
|
289
|
-
const initialClients = { ...createClients(config), ...opts?.clients };
|
|
290
|
-
const snapshots = RuntimeSnapshotManager.create(config, initialClients);
|
|
291
|
-
const inactiveReloadStatus: ReloadStatus = {
|
|
292
|
-
last_successful_reload_at: null,
|
|
293
|
-
last_reload_error: null,
|
|
294
|
-
restart_required_keys: [],
|
|
295
|
-
};
|
|
296
|
-
configure({ config, clients: initialClients });
|
|
297
|
-
metricsRegistry.setDeployment(describeDeployment(config));
|
|
298
|
-
// An injected config has no guaranteed file source. Watch it only when the
|
|
299
|
-
// caller explicitly supplies that source; normal server startup always watches.
|
|
300
|
-
const watcherPath =
|
|
301
|
-
opts?.configPath ?? (opts?.config ? undefined : configPath);
|
|
302
|
-
const configWatcher = watcherPath
|
|
303
|
-
? await ConfigWatcher.start(watcherPath, {
|
|
304
|
-
onReload: (nextConfig) => {
|
|
305
|
-
const nextClients = {
|
|
306
|
-
...createClients(nextConfig),
|
|
307
|
-
...opts?.clients,
|
|
308
|
-
};
|
|
309
|
-
snapshots.replace(nextConfig, nextClients);
|
|
310
|
-
configure({ config: nextConfig, clients: nextClients });
|
|
311
|
-
},
|
|
312
|
-
onError: (error) =>
|
|
313
|
-
console.error(...redactedErrorLog("skillmux config reload error:", error, redact)),
|
|
314
|
-
})
|
|
315
|
-
: undefined;
|
|
316
|
-
const stopWatcher = await startVaultWatcher();
|
|
317
|
-
const initPromise = initializeRuntime(readinessState)
|
|
318
|
-
.then(() => metricsRegistry.setReadiness(readinessState.get()))
|
|
319
|
-
.catch((err) => console.error(...redactedErrorLog("skillmux runtime init error:", err, redact)));
|
|
320
|
-
|
|
321
|
-
// AC14: fire-and-forget so this never delays readiness or blocks a resolve;
|
|
322
|
-
// not chained onto initPromise, which is awaited below for HTTP transport.
|
|
323
|
-
const runAuditPrune = () =>
|
|
324
|
-
pruneAuditIfDue().catch((err) => console.error(...redactedErrorLog("skillmux audit prune error:", err, redact)));
|
|
325
|
-
runAuditPrune();
|
|
326
|
-
const auditPruneInterval = setInterval(runAuditPrune, 24 * 60 * 60 * 1000);
|
|
327
|
-
auditPruneInterval.unref();
|
|
328
|
-
|
|
329
|
-
const server = createMcpServer();
|
|
330
|
-
|
|
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
|
-
|
|
342
|
-
if (transportType === "http") {
|
|
343
|
-
const { WebStandardStreamableHTTPServerTransport } =
|
|
344
|
-
await import("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js");
|
|
345
|
-
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
346
|
-
sessionIdGenerator: () => crypto.randomUUID(),
|
|
347
|
-
});
|
|
348
|
-
await server.connect(transport);
|
|
349
|
-
|
|
350
|
-
const { RateLimiter } = await import("./rate-limiter");
|
|
351
|
-
const rateLimiter = new RateLimiter(
|
|
352
|
-
config.server?.rate_limit || { enabled: false, requests_per_minute: 60 },
|
|
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
|
-
);
|
|
358
|
-
|
|
359
|
-
const port = opts?.port ?? Number(process.env.PORT || 3000);
|
|
360
|
-
const hostname = config.server?.hostname ?? "127.0.0.1";
|
|
361
|
-
assertSafeBindPosture(hostname, config.server?.auth_enabled ?? false);
|
|
362
|
-
const bunServer = Bun.serve({
|
|
363
|
-
port,
|
|
364
|
-
hostname,
|
|
365
|
-
maxRequestBodySize: config.server?.max_body_bytes ?? DEFAULT_MAX_BODY_BYTES,
|
|
366
|
-
async fetch(req, server) {
|
|
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
|
-
});
|
|
375
|
-
}
|
|
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,
|
|
388
|
-
});
|
|
389
|
-
} catch (error) {
|
|
390
|
-
concurrencyLimiter.release();
|
|
391
|
-
throw error;
|
|
392
|
-
}
|
|
393
|
-
|
|
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 });
|
|
415
|
-
}
|
|
416
|
-
|
|
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
|
-
});
|
|
426
|
-
}
|
|
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,
|
|
434
|
-
});
|
|
435
|
-
|
|
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);
|
|
454
|
-
if (allowOriginHeader) {
|
|
455
|
-
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
456
|
-
}
|
|
457
|
-
return new Response("Too Many Requests", {
|
|
458
|
-
status: 429,
|
|
459
|
-
headers,
|
|
460
|
-
});
|
|
461
|
-
}
|
|
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();
|
|
547
|
-
const headers = new Headers({ "Content-Type": "application/json" });
|
|
548
|
-
if (allowOriginHeader)
|
|
549
|
-
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
550
|
-
for (const [key, value] of Object.entries(rateLimitResult.headers))
|
|
551
|
-
headers.set(key, value);
|
|
552
|
-
return new Response(JSON.stringify(getStats(auditDb, since)), {
|
|
553
|
-
status: 200,
|
|
554
|
-
headers,
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
|
|
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
|
-
}
|
|
563
|
-
|
|
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
|
-
}
|
|
579
|
-
|
|
580
|
-
const headers = new Headers({ "Content-Type": "application/json" });
|
|
581
|
-
if (allowOriginHeader)
|
|
582
|
-
headers.set("Access-Control-Allow-Origin", allowOriginHeader);
|
|
583
|
-
|
|
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
|
-
}
|
|
603
|
-
|
|
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}"`);
|
|
614
|
-
return new Response(
|
|
615
|
-
JSON.stringify({
|
|
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
|
-
},
|
|
633
|
-
}),
|
|
634
|
-
{ status: 200, headers },
|
|
635
|
-
);
|
|
636
|
-
}
|
|
637
|
-
|
|
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
|
-
contextName: "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
|
-
}
|
|
694
|
-
|
|
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);
|
|
778
|
-
return new Response(
|
|
779
|
-
JSON.stringify({
|
|
780
|
-
...counts,
|
|
781
|
-
dry_run: false,
|
|
782
|
-
cutoff: cutoffIso,
|
|
783
|
-
}),
|
|
784
|
-
{ status: 200, headers },
|
|
785
|
-
);
|
|
786
|
-
}
|
|
787
|
-
|
|
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,
|
|
793
|
-
});
|
|
794
|
-
}
|
|
795
|
-
|
|
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
|
-
}
|
|
849
|
-
|
|
850
|
-
return new Response("Not Found", { status: 404, headers });
|
|
851
|
-
}
|
|
852
|
-
|
|
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
|
|
864
|
-
}
|
|
865
|
-
metricsRegistry.recordRequest(mcpMethod);
|
|
866
|
-
|
|
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
|
-
});
|
|
880
|
-
}
|
|
881
|
-
},
|
|
882
|
-
});
|
|
883
|
-
let stopped = false;
|
|
884
|
-
await initPromise;
|
|
885
|
-
console.log(`skillmux serving over HTTP on ${hostname}:${bunServer.port}`);
|
|
886
|
-
return {
|
|
887
|
-
port: bunServer.port,
|
|
888
|
-
statsPort: statsHandle?.port,
|
|
889
|
-
reloadStatus: () =>
|
|
890
|
-
configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
|
|
891
|
-
async stop() {
|
|
892
|
-
if (stopped) return;
|
|
893
|
-
stopped = true;
|
|
894
|
-
clearInterval(auditPruneInterval);
|
|
895
|
-
readinessState.set({ ...readinessState.get(), status: "stopping" });
|
|
896
|
-
metricsRegistry.setReadiness(readinessState.get());
|
|
897
|
-
bunServer.stop(true);
|
|
898
|
-
statsHandle?.stop();
|
|
899
|
-
configWatcher?.stop();
|
|
900
|
-
stopWatcher();
|
|
901
|
-
snapshots.dispose();
|
|
902
|
-
await server.close();
|
|
903
|
-
closeRuntime();
|
|
904
|
-
},
|
|
905
|
-
};
|
|
906
|
-
} else {
|
|
907
|
-
await server.connect(new StdioServerTransport());
|
|
908
|
-
let stopped = false;
|
|
909
|
-
return {
|
|
910
|
-
statsPort: statsHandle?.port,
|
|
911
|
-
reloadStatus: () =>
|
|
912
|
-
configWatcher?.reloadStatus() ?? { ...inactiveReloadStatus },
|
|
913
|
-
async stop() {
|
|
914
|
-
if (stopped) return;
|
|
915
|
-
stopped = true;
|
|
916
|
-
clearInterval(auditPruneInterval);
|
|
917
|
-
readinessState.set({ ...readinessState.get(), status: "stopping" });
|
|
918
|
-
metricsRegistry.setReadiness(readinessState.get());
|
|
919
|
-
statsHandle?.stop();
|
|
920
|
-
configWatcher?.stop();
|
|
921
|
-
stopWatcher();
|
|
922
|
-
snapshots.dispose();
|
|
923
|
-
await server.close();
|
|
924
|
-
closeRuntime();
|
|
925
|
-
},
|
|
926
|
-
};
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
if (import.meta.main) await startServer();
|