@sleep2agi/commhub-server 0.9.0-preview.4 → 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/package.json +1 -1
- package/src/api-nodes-shape.test.ts +89 -11
- package/src/db.ts +70 -0
- package/src/index.ts +42 -2
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/tools.ts +479 -0
- package/src/update-provider.test.ts +169 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
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
|
+
});
|