@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.5

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/src/tools.ts CHANGED
@@ -24,6 +24,22 @@ import {
24
24
  auditCreateNode,
25
25
  resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
26
26
  } from "./create-node.js";
27
+ import {
28
+ vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
29
+ VaultError,
30
+ } from "./vault.js";
31
+ import {
32
+ validateBaseUrl as _validateBaseUrl,
33
+ SUPPORTED_VENDORS,
34
+ ProbeValidationError,
35
+ } from "./probe-validate.js";
36
+ import {
37
+ putPendingProbeSecret,
38
+ newProbeId,
39
+ finalizeProbeAck,
40
+ startPendingProbeGcTimer,
41
+ startProbeSweeperTimer,
42
+ } from "./probe.js";
27
43
  import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
28
44
  import {
29
45
  ALLOWED_FLAGS,
@@ -2128,6 +2144,469 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2128
2144
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
2129
2145
  },
2130
2146
  );
2147
+
2148
+ // ── RFC-028 P1 — Provider & Model Registry + connectivity probe ──
2149
+ // Background timers (probe GC + sweeper) idempotent; safe to call
2150
+ // per registerTools.
2151
+ startPendingProbeGcTimer();
2152
+ startProbeSweeperTimer();
2153
+
2154
+ // Helper: zod to JSON-RPC reply for ProbeValidationError + VaultError
2155
+ const probeFailReply = (e: unknown) => {
2156
+ if (e instanceof ProbeValidationError) {
2157
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, ...(e.detail || {}) }) }] };
2158
+ }
2159
+ if (e instanceof VaultError) {
2160
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, message: e.message }) }] };
2161
+ }
2162
+ throw e;
2163
+ };
2164
+
2165
+ // §2.3.4 — upsert_network_secret (OWNER-ONLY; vault write).
2166
+ server.tool(
2167
+ "upsert_network_secret",
2168
+ "Write or replace a secret value in the network's vault (AES-GCM encrypted at rest). OWNER-only. RFC-028.",
2169
+ {
2170
+ key: z.string().min(1).max(64).regex(/^[A-Z][A-Z0-9_]{0,63}$/),
2171
+ value: z.string().min(1).max(16 * 1024),
2172
+ network_id: z.string().max(200).optional(),
2173
+ },
2174
+ async ({ key, value, network_id: clientNetId }) => {
2175
+ const effectiveNetId = getNetworkId(clientNetId);
2176
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2177
+ const callerRole = enforceUserId && effectiveNetId
2178
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
2179
+ : null;
2180
+ if (callerRole !== "owner") {
2181
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_owner_only", required_role: "owner", caller_role: callerRole }) }] };
2182
+ }
2183
+ try {
2184
+ vaultUpsert(effectiveNetId || "default", key, value);
2185
+ auditCreateNode({
2186
+ action: "create_node_dispatched", // reusing audit_log shape (provider follow-up logs)
2187
+ user_id: enforceUserId, network_id: effectiveNetId, target_id: null,
2188
+ detail: { op: "vault_upsert", key, network_id: effectiveNetId },
2189
+ });
2190
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, key }) }] };
2191
+ } catch (e) {
2192
+ return probeFailReply(e);
2193
+ }
2194
+ },
2195
+ );
2196
+
2197
+ // §2.3.4b — list_network_secrets (key names only; viewer+).
2198
+ server.tool(
2199
+ "list_network_secrets",
2200
+ "List vault key NAMES (NEVER values) for a network. RFC-028.",
2201
+ { network_id: z.string().max(200).optional() },
2202
+ async ({ network_id: clientNetId }) => {
2203
+ const effectiveNetId = getNetworkId(clientNetId);
2204
+ if (!canWrite(effectiveNetId) && enforceUserId) {
2205
+ // viewer-level: allow if caller has any role in network
2206
+ const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
2207
+ if (!role) return writeDeniedReply(effectiveNetId, "read");
2208
+ }
2209
+ const keys = vaultListKeys(effectiveNetId || "default");
2210
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, keys }) }] };
2211
+ },
2212
+ );
2213
+
2214
+ // §2.3.1 — upsert_provider (admin+).
2215
+ server.tool(
2216
+ "upsert_provider",
2217
+ "Create or update a provider (vendor + base_url + secret_key_ref + initial models). Admin+. RFC-028.",
2218
+ {
2219
+ name: z.string().min(1).max(100),
2220
+ vendor: z.string().min(1).max(64),
2221
+ base_url: z.string().min(1).max(500),
2222
+ secret_key_ref: z.string().min(1).max(64),
2223
+ models: z.array(z.object({
2224
+ model_name: z.string().min(1).max(100),
2225
+ display_name: z.string().max(100).optional(),
2226
+ context_window: z.number().int().min(0).optional(),
2227
+ supports_vision: z.boolean().optional(),
2228
+ })).optional(),
2229
+ network_id: z.string().max(200).optional(),
2230
+ },
2231
+ async ({ name, vendor, base_url, secret_key_ref, models, network_id: clientNetId }) => {
2232
+ const effectiveNetId = getNetworkId(clientNetId);
2233
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2234
+ const callerRole = enforceUserId && effectiveNetId
2235
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
2236
+ : null;
2237
+ if (callerRole !== "admin" && callerRole !== "owner") {
2238
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_provider", required_role: "admin", caller_role: callerRole }) }] };
2239
+ }
2240
+ try {
2241
+ _validateBaseUrl(vendor, base_url);
2242
+ } catch (e) {
2243
+ return probeFailReply(e);
2244
+ }
2245
+ // Verify vault key exists (best-effort; just lists names)
2246
+ const vaultKeys = vaultListKeys(effectiveNetId || "default");
2247
+ if (!vaultKeys.includes(secret_key_ref)) {
2248
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_not_in_vault", key: secret_key_ref, hint: "upsert_network_secret first" }) }] };
2249
+ }
2250
+ const providerId = `prov_${uuidv4()}`;
2251
+ try {
2252
+ db.run(
2253
+ `INSERT INTO providers (provider_id, network_id, name, vendor, base_url, secret_key_ref, created_at, created_by, enabled)
2254
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)`,
2255
+ [providerId, effectiveNetId || "default", name, vendor, base_url, secret_key_ref, Date.now(), enforceUserId || "unknown"],
2256
+ );
2257
+ } catch (e: any) {
2258
+ if (/UNIQUE constraint failed/.test(e?.message || "")) {
2259
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_name_conflict", name }) }] };
2260
+ }
2261
+ throw e;
2262
+ }
2263
+ const modelIds: string[] = [];
2264
+ if (models) {
2265
+ for (const m of models) {
2266
+ const mid = `pm_${uuidv4()}`;
2267
+ db.run(
2268
+ `INSERT INTO provider_models (model_id, provider_id, model_name, display_name, context_window, supports_vision, enabled, created_at)
2269
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)`,
2270
+ [mid, providerId, m.model_name, m.display_name ?? null, m.context_window ?? null, m.supports_vision ? 1 : 0, Date.now()],
2271
+ );
2272
+ modelIds.push(mid);
2273
+ }
2274
+ }
2275
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id: providerId, model_ids: modelIds }) }] };
2276
+ },
2277
+ );
2278
+
2279
+ // §2.3.2 — update_provider (admin+; patch semantics). RFC-028 P1.5.
2280
+ // Single tool for editing existing providers — patch model: name/base_url/
2281
+ // models/enabled all optional, at least one required. Vendor and
2282
+ // secret_key_ref are IMMUTABLE via this tool (vendor change = create+delete;
2283
+ // secret change goes through upsert_network_secret first). network_id is
2284
+ // immutable (cross-tenant move forbidden).
2285
+ //
2286
+ // base_url change re-runs validateBaseUrl with vendor read from DB row
2287
+ // (NOT from patch) — defends against trying to widen host allowlist by
2288
+ // smuggling a new vendor name. zod .strict() on the wrapping object
2289
+ // surfaces extras as -32602 at MCP boundary (R3 lock, same as
2290
+ // ack_probe_request per #308 fold-in).
2291
+ //
2292
+ // Audit: every successful update writes audit_log with before/after diff
2293
+ // of the changed fields ONLY (no secret values — secret isn't a patch
2294
+ // field, no leak path).
2295
+ server.registerTool(
2296
+ "update_provider",
2297
+ {
2298
+ description: "Edit existing provider (name/base_url/models/enabled). Patch semantics — at least one field. Vendor/secret/network_id immutable. Admin+. RFC-028 P1.5.",
2299
+ inputSchema: z.object({
2300
+ provider_id: z.string().regex(/^prov_[a-zA-Z0-9_-]+$/).max(200),
2301
+ network_id: z.string().max(200).optional(),
2302
+ patch: z.object({
2303
+ name: z.string().min(1).max(100).optional(),
2304
+ base_url: z.string().min(1).max(500).optional(),
2305
+ models: z.array(z.object({
2306
+ model_name: z.string().min(1).max(100),
2307
+ display_name: z.string().max(100).optional(),
2308
+ context_window: z.number().int().min(0).optional(),
2309
+ supports_vision: z.boolean().optional(),
2310
+ })).optional(),
2311
+ enabled: z.boolean().optional(),
2312
+ }).strict(),
2313
+ }).strict() as any,
2314
+ },
2315
+ async (args: any) => {
2316
+ const { provider_id, network_id: clientNetId, patch } = args;
2317
+ const effectiveNetId = getNetworkId(clientNetId);
2318
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2319
+ const callerRole = enforceUserId && effectiveNetId
2320
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
2321
+ : null;
2322
+ if (callerRole !== "admin" && callerRole !== "owner") {
2323
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_provider", required_role: "admin", caller_role: callerRole }) }] };
2324
+ }
2325
+
2326
+ // Empty patch — surface noop_no_changes (don't silently succeed)
2327
+ const patchKeys = Object.keys(patch || {});
2328
+ if (patchKeys.length === 0) {
2329
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "noop_no_changes", hint: "patch must include at least one of: name, base_url, models, enabled" }) }] };
2330
+ }
2331
+
2332
+ // SEC-1: row must exist within caller's network (SQL-level enforcement)
2333
+ const row = db.get<{ provider_id: string; vendor: string; name: string; base_url: string; enabled: number }>(
2334
+ `SELECT provider_id, vendor, name, base_url, enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
2335
+ provider_id, effectiveNetId || "default",
2336
+ );
2337
+ if (!row) {
2338
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_not_found", provider_id }) }] };
2339
+ }
2340
+
2341
+ // base_url change → re-run validateBaseUrl with vendor from DB (not patch)
2342
+ if (patch.base_url !== undefined && patch.base_url !== row.base_url) {
2343
+ try {
2344
+ _validateBaseUrl(row.vendor, patch.base_url);
2345
+ } catch (e) {
2346
+ return probeFailReply(e);
2347
+ }
2348
+ }
2349
+
2350
+ // Diff staging — collect before/after for audit (NO secret values; secret
2351
+ // isn't a patch field). Skip fields that didn't actually change.
2352
+ const diff: Record<string, { before: unknown; after: unknown }> = {};
2353
+ const sets: string[] = [];
2354
+ const params: unknown[] = [];
2355
+ let paramIdx = 1;
2356
+ if (patch.name !== undefined && patch.name !== row.name) {
2357
+ sets.push(`name = ?${paramIdx++}`); params.push(patch.name);
2358
+ diff.name = { before: row.name, after: patch.name };
2359
+ }
2360
+ if (patch.base_url !== undefined && patch.base_url !== row.base_url) {
2361
+ sets.push(`base_url = ?${paramIdx++}`); params.push(patch.base_url);
2362
+ diff.base_url = { before: row.base_url, after: patch.base_url };
2363
+ }
2364
+ if (patch.enabled !== undefined) {
2365
+ const newEnabled = patch.enabled ? 1 : 0;
2366
+ if (newEnabled !== row.enabled) {
2367
+ sets.push(`enabled = ?${paramIdx++}`); params.push(newEnabled);
2368
+ diff.enabled = { before: row.enabled === 1, after: patch.enabled };
2369
+ }
2370
+ }
2371
+
2372
+ // Replace models list (if supplied) atomically with the providers UPDATE
2373
+ const willReplaceModels = patch.models !== undefined;
2374
+ const newModelIds: string[] = [];
2375
+
2376
+ try {
2377
+ db.exec("BEGIN");
2378
+ if (sets.length > 0) {
2379
+ params.push(provider_id);
2380
+ try {
2381
+ db.run(`UPDATE providers SET ${sets.join(", ")} WHERE provider_id = ?${paramIdx}`, params);
2382
+ } catch (e: any) {
2383
+ if (/UNIQUE constraint failed/.test(e?.message || "")) {
2384
+ db.exec("ROLLBACK");
2385
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_name_conflict", name: patch.name }) }] };
2386
+ }
2387
+ throw e;
2388
+ }
2389
+ }
2390
+ if (willReplaceModels) {
2391
+ db.run(`DELETE FROM provider_models WHERE provider_id = ?1`, [provider_id]);
2392
+ for (const m of patch.models) {
2393
+ const mid = `pm_${uuidv4()}`;
2394
+ db.run(
2395
+ `INSERT INTO provider_models (model_id, provider_id, model_name, display_name, context_window, supports_vision, enabled, created_at)
2396
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)`,
2397
+ [mid, provider_id, m.model_name, m.display_name ?? null, m.context_window ?? null, m.supports_vision ? 1 : 0, Date.now()],
2398
+ );
2399
+ newModelIds.push(mid);
2400
+ }
2401
+ diff.models = { before: "(prior list)", after: `${patch.models.length} models replaced` };
2402
+ }
2403
+ db.run(
2404
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
2405
+ VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
2406
+ [enforceUserId || null, callerAlias || null, provider_id, JSON.stringify({ diff, fields_changed: Object.keys(diff) }), effectiveNetId || null],
2407
+ );
2408
+ db.exec("COMMIT");
2409
+ } catch (e: any) {
2410
+ try { db.exec("ROLLBACK"); } catch { /* ok */ }
2411
+ throw e;
2412
+ }
2413
+
2414
+ if (Object.keys(diff).length === 0 && !willReplaceModels) {
2415
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
2416
+ }
2417
+
2418
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2419
+ ok: true, provider_id,
2420
+ fields_changed: Object.keys(diff),
2421
+ models_replaced: willReplaceModels ? newModelIds.length : null,
2422
+ }) }] };
2423
+ },
2424
+ );
2425
+
2426
+ // §2.3.3 — list_providers (viewer+; never returns secret VALUES).
2427
+ server.tool(
2428
+ "list_providers",
2429
+ "List providers + models in the caller's network. Never returns secret VALUES. RFC-028.",
2430
+ { network_id: z.string().max(200).optional() },
2431
+ async ({ network_id: clientNetId }) => {
2432
+ const effectiveNetId = getNetworkId(clientNetId);
2433
+ // viewer access: caller must be member of network (read access)
2434
+ if (enforceUserId) {
2435
+ const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
2436
+ if (!role) return writeDeniedReply(effectiveNetId, "read");
2437
+ }
2438
+ const providers = db.all<any>(
2439
+ `SELECT provider_id, name, vendor, base_url, secret_key_ref, enabled FROM providers WHERE network_id = ?1 AND enabled = 1 ORDER BY name`,
2440
+ effectiveNetId || "default",
2441
+ );
2442
+ const out = providers.map(p => {
2443
+ const models = db.all<any>(
2444
+ `SELECT model_id, model_name, display_name, context_window, supports_vision, enabled FROM provider_models WHERE provider_id = ?1 AND enabled = 1 ORDER BY model_name`,
2445
+ p.provider_id,
2446
+ );
2447
+ return { ...p, in_vault: true, models }; // in_vault: true since we required it on upsert
2448
+ });
2449
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, providers: out }) }] };
2450
+ },
2451
+ );
2452
+
2453
+ // §2.3.5 — probe_provider_model (admin+; dispatches to daemon).
2454
+ server.tool(
2455
+ "probe_provider_model",
2456
+ "Dispatch a connectivity probe to a daemon. Mints ephemeral secret blob; daemon pulls via get_probe_request. Admin+. RFC-028.",
2457
+ {
2458
+ provider_id: z.string().min(1).max(200),
2459
+ model_name: z.string().min(1).max(100),
2460
+ daemon_node_id: z.string().min(1).max(200),
2461
+ network_id: z.string().max(200).optional(),
2462
+ },
2463
+ async ({ provider_id, model_name, daemon_node_id, network_id: clientNetId }) => {
2464
+ const effectiveNetId = getNetworkId(clientNetId);
2465
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2466
+ const callerRole = enforceUserId && effectiveNetId
2467
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
2468
+ : null;
2469
+ if (callerRole !== "admin" && callerRole !== "owner") {
2470
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_probe", required_role: "admin", caller_role: callerRole }) }] };
2471
+ }
2472
+ // Resolve provider + model (network-scoped). Distinguish "not found"
2473
+ // from "disabled" so the dashboard can render the right error
2474
+ // (P1.5 dashboard编辑/停用 needs this to explain why probe rejected).
2475
+ const provider = db.get<any>(
2476
+ `SELECT provider_id, vendor, base_url, secret_key_ref, enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
2477
+ provider_id, effectiveNetId || "default",
2478
+ );
2479
+ if (!provider) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_not_found", provider_id }) }] };
2480
+ if (provider.enabled !== 1) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_disabled", provider_id, hint: "update_provider with enabled:true to re-enable" }) }] };
2481
+ const model = db.get<any>(
2482
+ `SELECT model_id, model_name FROM provider_models WHERE provider_id = ?1 AND model_name = ?2 AND enabled = 1`,
2483
+ provider_id, model_name,
2484
+ );
2485
+ if (!model) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "model_not_found", model_name }) }] };
2486
+ // Resolve daemon (must be in caller network)
2487
+ const { row: daemon, sec1Ok } = resolveTargetNode(daemon_node_id, effectiveNetId);
2488
+ if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
2489
+ if (!sec1Ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
2490
+ // Vault decrypt the API key (may throw VaultError)
2491
+ let apiKey: string;
2492
+ try {
2493
+ const v = vaultGet(effectiveNetId || "default", provider.secret_key_ref);
2494
+ if (!v) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_not_in_vault", key: provider.secret_key_ref }) }] };
2495
+ apiKey = v;
2496
+ } catch (e) { return probeFailReply(e); }
2497
+ // Mint probe row + stash ephemeral blob
2498
+ const probeId = newProbeId();
2499
+ db.run(
2500
+ `INSERT INTO probe_results (probe_id, provider_id, model_name, daemon_node_id, network_id, status, probed_at, probed_by_user)
2501
+ VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
2502
+ [probeId, provider_id, model_name, daemon_node_id, effectiveNetId || "default", Date.now(), enforceUserId || null],
2503
+ );
2504
+ putPendingProbeSecret({
2505
+ probe_id: probeId,
2506
+ daemon_node_id,
2507
+ provider_id,
2508
+ vendor: provider.vendor,
2509
+ base_url: provider.base_url,
2510
+ model_name,
2511
+ api_key: apiKey,
2512
+ network_id: effectiveNetId || "default",
2513
+ });
2514
+ pushEvent(daemon.alias, { type: "probe_provider", probe_id: probeId }, daemon.network_id || effectiveNetId || "default");
2515
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, probe_id: probeId }) }] };
2516
+ },
2517
+ );
2518
+
2519
+ // §2.3.6 — get_probe_results (viewer+; matrix renderer source).
2520
+ server.tool(
2521
+ "get_probe_results",
2522
+ "Query probe history (optionally filtered by provider/model/daemon). Used by dashboard reachability matrix. RFC-028.",
2523
+ {
2524
+ provider_id: z.string().max(200).optional(),
2525
+ model_name: z.string().max(100).optional(),
2526
+ daemon_node_id: z.string().max(200).optional(),
2527
+ limit: z.number().int().min(1).max(500).optional(),
2528
+ network_id: z.string().max(200).optional(),
2529
+ },
2530
+ async ({ provider_id, model_name, daemon_node_id, limit, network_id: clientNetId }) => {
2531
+ const effectiveNetId = getNetworkId(clientNetId);
2532
+ if (enforceUserId) {
2533
+ const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
2534
+ if (!role) return writeDeniedReply(effectiveNetId, "read");
2535
+ }
2536
+ const where: string[] = ["network_id = ?1"];
2537
+ const params: any[] = [effectiveNetId || "default"];
2538
+ if (provider_id) { where.push(`provider_id = ?${params.length + 1}`); params.push(provider_id); }
2539
+ if (model_name) { where.push(`model_name = ?${params.length + 1}`); params.push(model_name); }
2540
+ if (daemon_node_id) { where.push(`daemon_node_id = ?${params.length + 1}`); params.push(daemon_node_id); }
2541
+ const sql = `SELECT probe_id, provider_id, model_name, daemon_node_id, status, latency_ms, error_label, probed_at, raw_status_code FROM probe_results WHERE ${where.join(" AND ")} ORDER BY probed_at DESC LIMIT ${limit ?? 100}`;
2542
+ const rows = db.all<any>(sql, ...params);
2543
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, results: rows }) }] };
2544
+ },
2545
+ );
2546
+
2547
+ // §2.3.7 daemon-facing — get_probe_request.
2548
+ server.tool(
2549
+ "get_probe_request",
2550
+ "Daemon pulls a pending probe request (called when SSE probe_provider doorbell arrives). RFC-028.",
2551
+ { probe_id: z.string().min(1).max(200) },
2552
+ async ({ probe_id }) => {
2553
+ const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
2554
+ if (!callerDaemon.ok) {
2555
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
2556
+ }
2557
+ // takePendingProbeSecret enforces daemon binding + evicts
2558
+ const blob = (await import("./probe.js")).takePendingProbeSecret(probe_id, callerDaemon.daemonNodeId);
2559
+ if (!blob) {
2560
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "probe_request_unavailable", reason: "not_found_or_wrong_daemon_or_expired" }) }] };
2561
+ }
2562
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2563
+ ok: true,
2564
+ probe_id: blob.probe_id,
2565
+ vendor: blob.vendor,
2566
+ base_url: blob.base_url,
2567
+ model_name: blob.model_name,
2568
+ api_key: blob.api_key, // ephemeral; daemon writes to .env.local or in-memory only
2569
+ }) }] };
2570
+ },
2571
+ );
2572
+
2573
+ // §2.3.8 daemon-facing — ack_probe_request (STRICT whitelist via zod).
2574
+ // R3 LOCK (RFC-028 v3): ack payload has EXACTLY 4 keys. We pass a
2575
+ // ZodObject with .strict() so the MCP SDK's validateToolInput surfaces
2576
+ // any extra field (e.g. an attacker smuggling `error_message` to leak a
2577
+ // secret) as a -32602 Invalid params error at the protocol boundary
2578
+ // BEFORE our handler runs. The SDK invokes z.object(shape) by default
2579
+ // which is strip-mode; passing a fully-constructed strict object
2580
+ // bypasses that and enforces unknownKeys='strict'.
2581
+ server.registerTool(
2582
+ "ack_probe_request",
2583
+ {
2584
+ description: "Daemon acks a probe. Schema is STRICT whitelist (no error_message; v3 R3 LOCK). RFC-028.",
2585
+ // Strict-mode ZodObject so the MCP SDK's validateToolInput surfaces
2586
+ // any extra field as a -32602 Invalid params error BEFORE the
2587
+ // handler runs. server.tool() accepts only ZodRawShape (default
2588
+ // strip-mode); registerTool() accepts a constructed ZodObject and
2589
+ // honors its unknownKeys policy.
2590
+ inputSchema: z.object({
2591
+ probe_id: z.string().min(1).max(200),
2592
+ status: z.enum(["ok", "auth_fail", "quota", "rate_limit", "network_error", "timeout", "redirect_forbidden", "vendor_5xx", "other_4xx", "tls_error", "probe_resolve_unsafe_ip", "probe_target_forbidden"]),
2593
+ raw_status_code: z.number().int().min(100).max(599).optional(),
2594
+ latency_ms: z.number().int().min(0).max(60_000),
2595
+ }).strict() as any,
2596
+ },
2597
+ async (args: any) => {
2598
+ const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
2599
+ if (!callerDaemon.ok) {
2600
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
2601
+ }
2602
+ try {
2603
+ const r = finalizeProbeAck(args, { network_id: callerDaemon.networkId, daemon_node_id: callerDaemon.daemonNodeId });
2604
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
2605
+ } catch (e) {
2606
+ return probeFailReply(e);
2607
+ }
2608
+ },
2609
+ );
2131
2610
  }
2132
2611
 
2133
2612
  // ────────────────────────────────────────────────────────────────────
@@ -0,0 +1,169 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import {
4
+ validateBaseUrl,
5
+ ProbeValidationError,
6
+ } from "./probe-validate.js";
7
+
8
+ // RFC-028 P1.5 — update_provider invariants. Tests the building blocks
9
+ // the MCP handler relies on:
10
+ // - SQL-level SEC-1 (provider_id × network_id uniqueness across tenants)
11
+ // - audit_log row written for every update
12
+ // - validateBaseUrl re-runs against vendor from DB (not patch) on
13
+ // base_url change → vendor immutability bypass attempt fails
14
+ // - patch-empty noop path
15
+ // - DELETE FROM provider_models + INSERT replacement is atomic
16
+ //
17
+ // Boundary behaviour (zod-strict patch wrapper, MCP -32602 on extras,
18
+ // admin gate, cross-tenant rejection) is covered by the docker e2e
19
+ // (qa-rfc028-update-provider). The unit layer focuses on the impl
20
+ // guarantees that survive even if the MCP boundary changes.
21
+
22
+ const NET_A = "net_test_pa_alpha";
23
+ const NET_B = "net_test_pa_beta";
24
+ const PROV_A = "prov_test_p15_alpha";
25
+ const PROV_B = "prov_test_p15_beta";
26
+
27
+ beforeEach(() => {
28
+ for (const p of [PROV_A, PROV_B]) {
29
+ try { db.run("DELETE FROM provider_models WHERE provider_id = ?1", [p]); } catch {}
30
+ try { db.run("DELETE FROM providers WHERE provider_id = ?1", [p]); } catch {}
31
+ }
32
+ try { db.run("DELETE FROM audit_log WHERE target_id IN (?1, ?2)", [PROV_A, PROV_B]); } catch {}
33
+ });
34
+
35
+ afterAll(() => {
36
+ for (const p of [PROV_A, PROV_B]) {
37
+ try { db.run("DELETE FROM provider_models WHERE provider_id = ?1", [p]); } catch {}
38
+ try { db.run("DELETE FROM providers WHERE provider_id = ?1", [p]); } catch {}
39
+ }
40
+ try { db.run("DELETE FROM audit_log WHERE target_id IN (?1, ?2)", [PROV_A, PROV_B]); } catch {}
41
+ });
42
+
43
+ function seedProvider(provider_id: string, network_id: string, opts: Partial<{ name: string; vendor: string; base_url: string; enabled: number }> = {}) {
44
+ db.run(
45
+ `INSERT INTO providers (provider_id, network_id, name, vendor, base_url, secret_key_ref, created_at, created_by, enabled)
46
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`,
47
+ [provider_id, network_id, opts.name ?? "anthro-prod", opts.vendor ?? "anthropic",
48
+ opts.base_url ?? "https://api.anthropic.com", "ANTHROPIC_API_KEY",
49
+ Date.now(), "test", opts.enabled ?? 1],
50
+ );
51
+ }
52
+
53
+ describe("update_provider — SQL invariants (RFC-028 P1.5)", () => {
54
+ test("SEC-1: provider_id is unique-per-network — netA query for netB provider_id returns nothing", () => {
55
+ seedProvider(PROV_A, NET_A);
56
+ seedProvider(PROV_B, NET_B);
57
+ // The handler's lookup query: SELECT ... WHERE provider_id = ? AND network_id = ?
58
+ const fromNetA = db.get<any>(
59
+ "SELECT provider_id FROM providers WHERE provider_id = ?1 AND network_id = ?2",
60
+ PROV_B, NET_A,
61
+ );
62
+ expect(fromNetA == null).toBe(true);
63
+ // Sanity: PROV_B does exist in its own network
64
+ const fromNetB = db.get<any>(
65
+ "SELECT provider_id FROM providers WHERE provider_id = ?1 AND network_id = ?2",
66
+ PROV_B, NET_B,
67
+ );
68
+ expect(fromNetB?.provider_id).toBe(PROV_B);
69
+ });
70
+
71
+ test("vendor immutability via validateBaseUrl: changing base_url uses DB vendor, attempt to widen via patched vendor fails", () => {
72
+ seedProvider(PROV_A, NET_A, { vendor: "anthropic" });
73
+ // Handler reads vendor from DB row, NOT from patch. Suppose patch tried
74
+ // to set vendor=evil + base_url=https://attacker.example.com — the
75
+ // handler ignores the vendor from patch (it's not a field in the
76
+ // zod patch schema), and validateBaseUrl runs with vendor='anthropic'
77
+ // from the DB, so the host check is applied.
78
+ const dbVendor = db.get<{vendor: string}>("SELECT vendor FROM providers WHERE provider_id = ?1", PROV_A)!.vendor;
79
+ expect(dbVendor).toBe("anthropic");
80
+ // attempting to use base_url not in anthropic allowlist fails
81
+ expect(() => validateBaseUrl(dbVendor, "https://attacker.example.com")).toThrow(ProbeValidationError);
82
+ expect(() => validateBaseUrl(dbVendor, "https://attacker.example.com")).toThrow(/probe_target_forbidden/);
83
+ // valid anthropic host passes
84
+ expect(() => validateBaseUrl(dbVendor, "https://api.anthropic.com/v1")).not.toThrow();
85
+ });
86
+
87
+ test("patch noop: enabled set to same value → SET clause skipped", () => {
88
+ seedProvider(PROV_A, NET_A, { enabled: 1 });
89
+ const before = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)!.enabled;
90
+ expect(before).toBe(1);
91
+ // simulate the handler's diff logic: if patch.enabled === row.enabled, skip
92
+ const newEnabled = true ? 1 : 0;
93
+ const shouldUpdate = newEnabled !== before;
94
+ expect(shouldUpdate).toBe(false);
95
+ // verify no SET ran (row unchanged)
96
+ const after = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)!.enabled;
97
+ expect(after).toBe(1);
98
+ });
99
+
100
+ test("enabled toggle 1→0 written + audit log row created in same transaction", () => {
101
+ seedProvider(PROV_A, NET_A, { enabled: 1 });
102
+ db.exec("BEGIN");
103
+ db.run("UPDATE providers SET enabled = 0 WHERE provider_id = ?1", [PROV_A]);
104
+ db.run(
105
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
106
+ VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
107
+ ["u_test", "test", PROV_A, JSON.stringify({ diff: { enabled: { before: true, after: false } } }), NET_A],
108
+ );
109
+ db.exec("COMMIT");
110
+ expect(db.get<{enabled:number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)?.enabled).toBe(0);
111
+ const audit = db.get<any>("SELECT action, target_id, detail FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'", PROV_A);
112
+ expect(audit?.action).toBe("update_provider");
113
+ expect(audit?.target_id).toBe(PROV_A);
114
+ expect(JSON.parse(audit!.detail).diff.enabled.after).toBe(false);
115
+ });
116
+
117
+ test("audit detail does NOT contain secret_key_ref VALUE under any patch", () => {
118
+ seedProvider(PROV_A, NET_A);
119
+ // Even if an attacker tried to slip secret into the diff (impossible
120
+ // through the schema, but defense-in-depth), the audit serializer
121
+ // only includes the fields actually in `diff`. Secret isn't a patch
122
+ // field, so it can't reach the diff. Verify the absence:
123
+ const auditPayload = JSON.stringify({ diff: { name: { before: "old", after: "new" } } });
124
+ expect(auditPayload).not.toContain("secret");
125
+ expect(auditPayload).not.toContain("ANTHROPIC_API_KEY");
126
+ expect(auditPayload).not.toContain("sk-");
127
+ });
128
+
129
+ test("provider_models DELETE+INSERT atomicity (transaction wraps both)", () => {
130
+ seedProvider(PROV_A, NET_A);
131
+ db.run(
132
+ `INSERT INTO provider_models (model_id, provider_id, model_name, enabled, created_at)
133
+ VALUES ('pm_old1', ?1, 'claude-old-1', 1, ?2), ('pm_old2', ?1, 'claude-old-2', 1, ?2)`,
134
+ [PROV_A, Date.now()],
135
+ );
136
+ expect(db.all("SELECT model_id FROM provider_models WHERE provider_id = ?1", PROV_A).length).toBe(2);
137
+ // Replace in one transaction
138
+ db.exec("BEGIN");
139
+ db.run("DELETE FROM provider_models WHERE provider_id = ?1", [PROV_A]);
140
+ db.run(
141
+ `INSERT INTO provider_models (model_id, provider_id, model_name, enabled, created_at)
142
+ VALUES ('pm_new1', ?1, 'claude-new-1', 1, ?2)`,
143
+ [PROV_A, Date.now()],
144
+ );
145
+ db.exec("COMMIT");
146
+ const rows = db.all<{model_name: string}>("SELECT model_name FROM provider_models WHERE provider_id = ?1", PROV_A);
147
+ expect(rows.length).toBe(1);
148
+ expect(rows[0].model_name).toBe("claude-new-1");
149
+ });
150
+
151
+ test("base_url change rejected via validateBaseUrl when host not in vendor allowlist", () => {
152
+ seedProvider(PROV_A, NET_A, { vendor: "anthropic" });
153
+ const vendor = db.get<{vendor: string}>("SELECT vendor FROM providers WHERE provider_id = ?1", PROV_A)!.vendor;
154
+ // any host other than api.anthropic.com is rejected
155
+ expect(() => validateBaseUrl(vendor, "https://api.openai.com")).toThrow(/probe_target_forbidden/);
156
+ expect(() => validateBaseUrl(vendor, "https://169.254.169.254/v1")).toThrow(/probe_target_forbidden/);
157
+ expect(() => validateBaseUrl(vendor, "http://api.anthropic.com")).toThrow(/probe_base_url_invalid/);
158
+ });
159
+
160
+ test("provider_disabled error distinct from provider_not_found in probe lookup", () => {
161
+ seedProvider(PROV_A, NET_A, { enabled: 0 });
162
+ // The new probe handler does the lookup WITHOUT enabled filter, then
163
+ // checks .enabled — distinguishing not_found vs disabled.
164
+ const row = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2", PROV_A, NET_A);
165
+ expect(row).toBeTruthy();
166
+ expect(row!.enabled).toBe(0);
167
+ // dashboard renders different message for these two cases
168
+ });
169
+ });