@synapsor/runner 1.5.0 → 1.5.4
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 +134 -3
- package/README.md +108 -113
- package/dist/authoring.d.ts +23 -0
- package/dist/authoring.d.ts.map +1 -0
- package/dist/authoring.mjs +1318 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +4 -5
- package/dist/local-ui.d.ts +14 -0
- package/dist/local-ui.d.ts.map +1 -1
- package/dist/runner.mjs +12334 -5256
- package/dist/runtime.mjs +817 -149
- package/dist/shadow.d.ts +36 -0
- package/dist/shadow.d.ts.map +1 -0
- package/dist/shadow.mjs +5362 -0
- package/docs/README.md +25 -2
- package/docs/alternatives.md +122 -0
- package/docs/capability-authoring.md +49 -1
- package/docs/client-recipes.md +218 -0
- package/docs/cloud-mode.md +18 -0
- package/docs/contract-testing.md +9 -7
- package/docs/cursor-plugin.md +78 -0
- package/docs/database-enforced-scope.md +8 -0
- package/docs/dsl-reference.md +45 -4
- package/docs/effect-regression.md +39 -2
- package/docs/fresh-developer-usability.md +110 -0
- package/docs/getting-started-own-database.md +102 -5
- package/docs/guarded-crud-writeback.md +10 -1
- package/docs/host-compatibility.md +59 -0
- package/docs/http-mcp.md +222 -207
- package/docs/limitations.md +9 -2
- package/docs/local-mode.md +11 -7
- package/docs/mcp-audit.md +166 -2
- package/docs/mcp-client-setup.md +28 -11
- package/docs/mcp-clients.md +43 -8
- package/docs/openai-agents-sdk.md +16 -2
- package/docs/oss-vs-cloud.md +3 -0
- package/docs/production.md +72 -7
- package/docs/release-notes.md +131 -4
- package/docs/runner-bundles.md +7 -2
- package/docs/runner-config-reference.md +96 -6
- package/docs/running-a-runner-fleet.md +42 -13
- package/docs/security-boundary.md +58 -8
- package/docs/shadow-studies.md +47 -4
- package/docs/store-lifecycle.md +93 -5
- package/docs/troubleshooting-first-run.md +46 -0
- package/examples/openai-agents-http/README.md +10 -4
- package/examples/openai-agents-http/agent.py +2 -2
- package/examples/runner-fleet/Dockerfile +7 -2
- package/examples/runner-fleet/README.md +11 -7
- package/examples/runner-fleet/docker-compose.yml +4 -4
- package/examples/runner-fleet/mint-dev-token.mjs +1 -1
- package/examples/runner-fleet/start-tls-runner.sh +21 -0
- package/examples/runner-fleet/synapsor.runner.json +27 -1
- package/examples/support-billing-agent/README.md +18 -0
- package/examples/support-billing-agent/app/README.md +6 -0
- package/examples/support-billing-agent/app/contract.ts +28 -0
- package/examples/support-billing-agent/app/effect-adapter.mjs +23 -0
- package/examples/support-billing-agent/app/record-shadow-outcomes.mjs +44 -0
- package/examples/support-billing-agent/scripts/run-evaluation.sh +6 -5
- package/examples/support-plan-credit/README.md +54 -3
- package/examples/support-plan-credit/mcp-client-examples/README.md +23 -0
- package/examples/support-plan-credit/mcp-client-examples/claude-code.sh +34 -0
- package/examples/support-plan-credit/mcp-client-examples/codex.config.toml +24 -0
- package/examples/support-plan-credit/mcp-client-examples/generic-stdio.mjs +35 -0
- package/examples/support-plan-credit/mcp-client-examples/generic-streamable-http.json +4 -1
- package/examples/support-plan-credit/mcp-client-examples/generic-streamable-http.mjs +31 -0
- package/examples/support-plan-credit/mcp-client-examples/google-adk.py +40 -0
- package/examples/support-plan-credit/mcp-client-examples/langchain.mjs +29 -0
- package/examples/support-plan-credit/mcp-client-examples/llamaindex.py +36 -0
- package/examples/support-plan-credit/mcp-client-examples/openai-agents-stdio.ts +1 -1
- package/examples/support-plan-credit/mcp-client-examples/openai-agents-streamable-http.ts +5 -1
- package/examples/support-plan-credit/mcp-client-examples/vscode.mcp.json +20 -0
- package/examples/support-plan-credit/synapsor/actions/support.propose_plan_credit.contract-tests.generated.json +221 -0
- package/examples/support-plan-credit/synapsor/actions/support.propose_plan_credit.ts +34 -0
- package/examples/support-plan-credit/synapsor.contract.json +0 -3
- package/fixtures/mcp-audit/README.md +14 -0
- package/fixtures/mcp-audit/cursor-bypass-config.json +38 -0
- package/fixtures/mcp-audit/dangerous-tools-list.json +33 -0
- package/fixtures/mcp-audit/reviewed-proposal-tools-list.json +60 -0
- package/package.json +18 -3
- package/schemas/mcp-audit-report.schema.json +100 -1
- package/schemas/synapsor.contract-tests.schema.json +1 -1
- package/schemas/synapsor.runner.schema.json +80 -0
package/dist/runtime.mjs
CHANGED
|
@@ -11,9 +11,11 @@ import fs2 from "node:fs";
|
|
|
11
11
|
import { createServer } from "node:http";
|
|
12
12
|
import { createServer as createHttpsServer } from "node:https";
|
|
13
13
|
import path2 from "node:path";
|
|
14
|
+
import { createSecureContext } from "node:tls";
|
|
14
15
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
16
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
17
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
18
|
+
import { OAuthProtectedResourceMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
17
19
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
18
20
|
|
|
19
21
|
// node_modules/.pnpm/@modelcontextprotocol+ext-apps@1.7.4_@modelcontextprotocol+sdk@1.29.0_zod@3.25.76__zod@3.25.76/node_modules/@modelcontextprotocol/ext-apps/dist/src/server/index.js
|
|
@@ -118,7 +120,7 @@ function N3(Z, $, J, X, V) {
|
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
// packages/config/src/index.ts
|
|
121
|
-
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "mode", "storage", "sources", "trusted_context", "contexts", "executors", "capabilities", "contracts", "policies", "approvals", "operator_identity", "session_auth", "rate_limits", "metrics", "graduated_trust", "cloud", "governance", "strict", "result_format"]);
|
|
123
|
+
var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "mode", "storage", "sources", "trusted_context", "contexts", "executors", "capabilities", "contracts", "policies", "approvals", "operator_identity", "session_auth", "http_security", "rate_limits", "metrics", "graduated_trust", "cloud", "governance", "strict", "result_format"]);
|
|
122
124
|
var STORAGE_KEYS = /* @__PURE__ */ new Set(["sqlite_path", "shared_postgres"]);
|
|
123
125
|
var SHARED_POSTGRES_STORAGE_KEYS = /* @__PURE__ */ new Set(["mode", "url_env", "schema", "lock_timeout_ms", "max_entries"]);
|
|
124
126
|
var APPROVALS_KEYS = /* @__PURE__ */ new Set(["disable_auto_approval"]);
|
|
@@ -180,6 +182,26 @@ var SESSION_AUTH_KEYS = /* @__PURE__ */ new Set([
|
|
|
180
182
|
"fetch_timeout_ms",
|
|
181
183
|
"max_response_bytes"
|
|
182
184
|
]);
|
|
185
|
+
var HTTP_SECURITY_KEYS = /* @__PURE__ */ new Set(["deployment", "channel", "static_token", "oauth_resource", "allowed_origins", "allowed_hosts", "limits"]);
|
|
186
|
+
var HTTP_STATIC_TOKEN_KEYS = /* @__PURE__ */ new Set(["active_env", "previous_env"]);
|
|
187
|
+
var HTTP_OAUTH_RESOURCE_KEYS = /* @__PURE__ */ new Set([
|
|
188
|
+
"resource",
|
|
189
|
+
"authorization_servers",
|
|
190
|
+
"scopes_supported",
|
|
191
|
+
"required_scopes",
|
|
192
|
+
"resource_name",
|
|
193
|
+
"resource_documentation"
|
|
194
|
+
]);
|
|
195
|
+
var HTTP_LIMIT_KEYS = /* @__PURE__ */ new Set([
|
|
196
|
+
"max_request_bytes",
|
|
197
|
+
"max_header_bytes",
|
|
198
|
+
"max_sessions",
|
|
199
|
+
"session_idle_timeout_seconds",
|
|
200
|
+
"request_timeout_ms",
|
|
201
|
+
"headers_timeout_ms",
|
|
202
|
+
"keep_alive_timeout_ms",
|
|
203
|
+
"max_connections"
|
|
204
|
+
]);
|
|
183
205
|
var RATE_LIMITS_KEYS = /* @__PURE__ */ new Set(["enabled", "default", "capabilities"]);
|
|
184
206
|
var RATE_LIMIT_RULE_KEYS = /* @__PURE__ */ new Set(["requests", "window_seconds"]);
|
|
185
207
|
var CLOUD_KEYS = /* @__PURE__ */ new Set(["base_url_env", "runner_token_env", "runner_id", "runner_version", "project_id", "adapter_id", "source_id", "engines", "capabilities", "session"]);
|
|
@@ -337,6 +359,7 @@ function validateRunnerCapabilityConfig(input) {
|
|
|
337
359
|
validatePolicies(input.policies, strict, errors);
|
|
338
360
|
validateOperatorIdentity(input.operator_identity, strict, errors);
|
|
339
361
|
validateSessionAuth(input.session_auth, input.trusted_context, input.contexts, strict, errors);
|
|
362
|
+
validateHttpSecurity(input.http_security, input.session_auth, input.trusted_context, input.contexts, strict, errors, warnings);
|
|
340
363
|
validateRateLimits(input.rate_limits, strict, errors);
|
|
341
364
|
validateMetrics(input.metrics, strict, errors);
|
|
342
365
|
validateGraduatedTrust(input.graduated_trust, strict, errors);
|
|
@@ -347,6 +370,169 @@ function validateRunnerCapabilityConfig(input) {
|
|
|
347
370
|
scanForForbiddenFields(input, "$", errors);
|
|
348
371
|
return { ok: errors.length === 0, errors, warnings };
|
|
349
372
|
}
|
|
373
|
+
function validateHttpSecurity(value, sessionAuth, trustedContext, contexts, strict, errors, warnings) {
|
|
374
|
+
if (value === void 0) return;
|
|
375
|
+
if (!isRecord(value)) {
|
|
376
|
+
errors.push({ path: "$.http_security", code: "HTTP_SECURITY_NOT_OBJECT", message: "http_security must be a Runner deployment configuration object." });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (strict) checkUnknownKeys(value, HTTP_SECURITY_KEYS, "$.http_security", errors);
|
|
380
|
+
const deployment = value.deployment;
|
|
381
|
+
if (deployment !== void 0 && deployment !== "loopback" && deployment !== "single_tenant" && deployment !== "shared") {
|
|
382
|
+
errors.push({ path: "$.http_security.deployment", code: "INVALID_HTTP_DEPLOYMENT", message: "http_security.deployment must be loopback, single_tenant, or shared." });
|
|
383
|
+
}
|
|
384
|
+
const channel = value.channel;
|
|
385
|
+
if (channel !== void 0 && channel !== "direct_tls" && channel !== "trusted_tls_proxy" && channel !== "insecure_http_break_glass") {
|
|
386
|
+
errors.push({ path: "$.http_security.channel", code: "INVALID_HTTP_CHANNEL", message: "http_security.channel must be direct_tls, trusted_tls_proxy, or insecure_http_break_glass." });
|
|
387
|
+
}
|
|
388
|
+
validateHttpStaticToken(value.static_token, strict, errors);
|
|
389
|
+
validateHttpOauthResource(value.oauth_resource, strict, errors);
|
|
390
|
+
validateExactOrigins(value.allowed_origins, errors);
|
|
391
|
+
validateAllowedHosts(value.allowed_hosts, errors);
|
|
392
|
+
validateHttpLimits(value.limits, strict, errors);
|
|
393
|
+
const contextsUseClaims = isRecord(trustedContext) && trustedContext.provider === "http_claims" || isRecord(contexts) && Object.values(contexts).some((context) => isRecord(context) && context.provider === "http_claims");
|
|
394
|
+
if (deployment === "shared") {
|
|
395
|
+
if (!contextsUseClaims) {
|
|
396
|
+
errors.push({ path: "$.http_security.deployment", code: "SHARED_HTTP_CLAIMS_REQUIRED", message: "shared HTTP deployment requires http_claims trusted context so tenant and principal come from verified per-session identity." });
|
|
397
|
+
}
|
|
398
|
+
if (!isRecord(sessionAuth)) {
|
|
399
|
+
errors.push({ path: "$.session_auth", code: "SHARED_HTTP_SESSION_AUTH_REQUIRED", message: "shared HTTP deployment requires signed session_auth." });
|
|
400
|
+
} else {
|
|
401
|
+
if (!isNonEmptyString(sessionAuth.issuer)) errors.push({ path: "$.session_auth.issuer", code: "SHARED_HTTP_ISSUER_REQUIRED", message: "shared HTTP deployment requires an exact JWT issuer." });
|
|
402
|
+
if (!isNonEmptyString(sessionAuth.audience)) errors.push({ path: "$.session_auth.audience", code: "SHARED_HTTP_AUDIENCE_REQUIRED", message: "shared HTTP deployment requires an exact JWT audience/resource." });
|
|
403
|
+
if (sessionAuth.provider === "jwt_hs256") {
|
|
404
|
+
warnings.push({ path: "$.session_auth.provider", code: "SHARED_HTTP_HS256_WARNING", message: "Shared HTTP uses a symmetric JWT verification key. Prefer jwt_asymmetric with RS256/ES256 and JWKS for production." });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (!isRecord(value.oauth_resource)) {
|
|
408
|
+
errors.push({ path: "$.http_security.oauth_resource", code: "SHARED_HTTP_OAUTH_RESOURCE_REQUIRED", message: "shared HTTP deployment requires RFC 9728 protected-resource metadata for an external authorization server." });
|
|
409
|
+
} else if (isRecord(sessionAuth) && isNonEmptyString(sessionAuth.audience) && value.oauth_resource.resource !== sessionAuth.audience) {
|
|
410
|
+
errors.push({ path: "$.http_security.oauth_resource.resource", code: "HTTP_RESOURCE_AUDIENCE_MISMATCH", message: "oauth_resource.resource must exactly match session_auth.audience so tokens are bound to this Runner resource." });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (deployment !== "shared" && value.oauth_resource !== void 0 && !contextsUseClaims) {
|
|
414
|
+
errors.push({ path: "$.http_security.oauth_resource", code: "HTTP_OAUTH_CLAIMS_REQUIRED", message: "OAuth protected-resource metadata requires signed http_claims session identity." });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function validateHttpStaticToken(value, strict, errors) {
|
|
418
|
+
if (value === void 0) return;
|
|
419
|
+
if (!isRecord(value)) {
|
|
420
|
+
errors.push({ path: "$.http_security.static_token", code: "HTTP_STATIC_TOKEN_NOT_OBJECT", message: "http_security.static_token must contain environment-variable names only." });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (strict) checkUnknownKeys(value, HTTP_STATIC_TOKEN_KEYS, "$.http_security.static_token", errors);
|
|
424
|
+
if (value.active_env !== void 0 && !isEnvName(value.active_env)) {
|
|
425
|
+
errors.push({ path: "$.http_security.static_token.active_env", code: "INVALID_HTTP_TOKEN_ENV", message: "active_env must name the environment variable containing the opaque endpoint token." });
|
|
426
|
+
}
|
|
427
|
+
if (value.previous_env !== void 0 && !isEnvName(value.previous_env)) {
|
|
428
|
+
errors.push({ path: "$.http_security.static_token.previous_env", code: "INVALID_HTTP_PREVIOUS_TOKEN_ENV", message: "previous_env must name the one previous token accepted during an operator-controlled rotation window." });
|
|
429
|
+
}
|
|
430
|
+
if (value.active_env !== void 0 && value.active_env === value.previous_env) {
|
|
431
|
+
errors.push({ path: "$.http_security.static_token.previous_env", code: "HTTP_TOKEN_ENV_REUSED", message: "active_env and previous_env must be different environment variables." });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function validateHttpOauthResource(value, strict, errors) {
|
|
435
|
+
if (value === void 0) return;
|
|
436
|
+
if (!isRecord(value)) {
|
|
437
|
+
errors.push({ path: "$.http_security.oauth_resource", code: "HTTP_OAUTH_RESOURCE_NOT_OBJECT", message: "oauth_resource must describe this Runner protected resource and its external authorization server." });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (strict) checkUnknownKeys(value, HTTP_OAUTH_RESOURCE_KEYS, "$.http_security.oauth_resource", errors);
|
|
441
|
+
if (!isHttpsUrl(value.resource)) {
|
|
442
|
+
errors.push({ path: "$.http_security.oauth_resource.resource", code: "INVALID_HTTP_OAUTH_RESOURCE", message: "oauth_resource.resource must be an HTTPS URL for the exact public MCP endpoint." });
|
|
443
|
+
}
|
|
444
|
+
if (!Array.isArray(value.authorization_servers) || value.authorization_servers.length < 1 || value.authorization_servers.length > 8) {
|
|
445
|
+
errors.push({ path: "$.http_security.oauth_resource.authorization_servers", code: "HTTP_AUTHORIZATION_SERVERS_REQUIRED", message: "oauth_resource.authorization_servers must contain 1 through 8 external authorization-server HTTPS issuer URLs." });
|
|
446
|
+
} else {
|
|
447
|
+
value.authorization_servers.forEach((server, index) => {
|
|
448
|
+
if (!isHttpsUrl(server)) errors.push({ path: `$.http_security.oauth_resource.authorization_servers[${index}]`, code: "INVALID_HTTP_AUTHORIZATION_SERVER", message: "authorization server URLs must use HTTPS and contain no credentials, query, or fragment." });
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
for (const key of ["scopes_supported", "required_scopes"]) {
|
|
452
|
+
const scopes = value[key];
|
|
453
|
+
if (scopes !== void 0 && (!Array.isArray(scopes) || scopes.length < 1 || scopes.length > 64 || scopes.some((scope) => !isOAuthScope(scope)))) {
|
|
454
|
+
errors.push({ path: `$.http_security.oauth_resource.${key}`, code: "INVALID_HTTP_OAUTH_SCOPES", message: `${key} must contain 1 through 64 unique, visible OAuth scope strings.` });
|
|
455
|
+
} else if (Array.isArray(scopes) && new Set(scopes).size !== scopes.length) {
|
|
456
|
+
errors.push({ path: `$.http_security.oauth_resource.${key}`, code: "DUPLICATE_HTTP_OAUTH_SCOPE", message: `${key} must not contain duplicate scopes.` });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (Array.isArray(value.required_scopes) && Array.isArray(value.scopes_supported)) {
|
|
460
|
+
for (const scope of value.required_scopes) {
|
|
461
|
+
if (!value.scopes_supported.includes(scope)) errors.push({ path: "$.http_security.oauth_resource.required_scopes", code: "UNSUPPORTED_HTTP_OAUTH_SCOPE", message: `Required scope ${String(scope)} is not listed in scopes_supported.` });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (value.resource_name !== void 0 && (!isNonEmptyString(value.resource_name) || value.resource_name.trim().length > 128)) {
|
|
465
|
+
errors.push({ path: "$.http_security.oauth_resource.resource_name", code: "INVALID_HTTP_RESOURCE_NAME", message: "resource_name must be 1 through 128 characters." });
|
|
466
|
+
}
|
|
467
|
+
if (value.resource_documentation !== void 0 && !isHttpsUrl(value.resource_documentation)) {
|
|
468
|
+
errors.push({ path: "$.http_security.oauth_resource.resource_documentation", code: "INVALID_HTTP_RESOURCE_DOCUMENTATION", message: "resource_documentation must be an HTTPS URL without credentials, query, or fragment." });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
function validateExactOrigins(value, errors) {
|
|
472
|
+
if (value === void 0) return;
|
|
473
|
+
if (!Array.isArray(value) || value.length > 32) {
|
|
474
|
+
errors.push({ path: "$.http_security.allowed_origins", code: "INVALID_HTTP_ALLOWED_ORIGINS", message: "allowed_origins must be an array of at most 32 exact origins." });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
value.forEach((origin, index) => {
|
|
478
|
+
if (!isExactHttpOrigin(origin)) errors.push({ path: `$.http_security.allowed_origins[${index}]`, code: "INVALID_HTTP_ALLOWED_ORIGIN", message: "Each allowed origin must be an exact HTTP(S) origin; wildcards, credentials, paths, query strings, and fragments are forbidden." });
|
|
479
|
+
});
|
|
480
|
+
if (new Set(value).size !== value.length) errors.push({ path: "$.http_security.allowed_origins", code: "DUPLICATE_HTTP_ALLOWED_ORIGIN", message: "allowed_origins must not contain duplicates." });
|
|
481
|
+
}
|
|
482
|
+
function validateAllowedHosts(value, errors) {
|
|
483
|
+
if (value === void 0) return;
|
|
484
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 32 || value.some((host) => !isAllowedHost(host))) {
|
|
485
|
+
errors.push({ path: "$.http_security.allowed_hosts", code: "INVALID_HTTP_ALLOWED_HOSTS", message: "allowed_hosts must contain 1 through 32 exact host or host:port authorities; wildcards and forwarded-host syntax are forbidden." });
|
|
486
|
+
} else if (new Set(value.map((host) => String(host).toLowerCase())).size !== value.length) {
|
|
487
|
+
errors.push({ path: "$.http_security.allowed_hosts", code: "DUPLICATE_HTTP_ALLOWED_HOST", message: "allowed_hosts must not contain duplicates." });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function validateHttpLimits(value, strict, errors) {
|
|
491
|
+
if (value === void 0) return;
|
|
492
|
+
if (!isRecord(value)) {
|
|
493
|
+
errors.push({ path: "$.http_security.limits", code: "HTTP_LIMITS_NOT_OBJECT", message: "http_security.limits must be an object." });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (strict) checkUnknownKeys(value, HTTP_LIMIT_KEYS, "$.http_security.limits", errors);
|
|
497
|
+
for (const [key, minimum, maximum] of [
|
|
498
|
+
["max_request_bytes", 1024, 16 * 1024 * 1024],
|
|
499
|
+
["max_header_bytes", 4096, 64 * 1024],
|
|
500
|
+
["max_sessions", 1, 1e5],
|
|
501
|
+
["session_idle_timeout_seconds", 10, 86400],
|
|
502
|
+
["request_timeout_ms", 1e3, 3e5],
|
|
503
|
+
["headers_timeout_ms", 1e3, 12e4],
|
|
504
|
+
["keep_alive_timeout_ms", 1e3, 12e4],
|
|
505
|
+
["max_connections", 1, 1e5]
|
|
506
|
+
]) {
|
|
507
|
+
if (value[key] !== void 0 && (!Number.isSafeInteger(value[key]) || Number(value[key]) < minimum || Number(value[key]) > maximum)) {
|
|
508
|
+
errors.push({ path: `$.http_security.limits.${key}`, code: "INVALID_HTTP_LIMIT", message: `${key} must be an integer from ${minimum} through ${maximum}.` });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function isHttpsUrl(value) {
|
|
513
|
+
if (typeof value !== "string") return false;
|
|
514
|
+
try {
|
|
515
|
+
const url = new URL(value);
|
|
516
|
+
return url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash;
|
|
517
|
+
} catch {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function isExactHttpOrigin(value) {
|
|
522
|
+
if (typeof value !== "string" || value === "*" || value === "null") return false;
|
|
523
|
+
try {
|
|
524
|
+
const url = new URL(value);
|
|
525
|
+
return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password && url.pathname === "/" && !url.search && !url.hash && url.origin === value;
|
|
526
|
+
} catch {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function isAllowedHost(value) {
|
|
531
|
+
return typeof value === "string" && value.length <= 255 && value === value.trim() && !/[\s,/*\\?#]/.test(value) && !value.includes("://") && /^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+)(?::\d{1,5})?$/.test(value);
|
|
532
|
+
}
|
|
533
|
+
function isOAuthScope(value) {
|
|
534
|
+
return typeof value === "string" && value.length >= 1 && value.length <= 128 && /^[\x21\x23-\x5b\x5d-\x7e]+$/.test(value);
|
|
535
|
+
}
|
|
350
536
|
function validateGovernance(value, strict, errors) {
|
|
351
537
|
if (value === void 0) return;
|
|
352
538
|
if (!isRecord(value)) {
|
|
@@ -4557,6 +4743,11 @@ var ProposalStore = class {
|
|
|
4557
4743
|
const rows = this.db.prepare(query.sql).all(...query.params);
|
|
4558
4744
|
return rows.map((row) => rowToProposal(row)).filter((proposal) => proposal !== void 0);
|
|
4559
4745
|
}
|
|
4746
|
+
countProposals(filters = {}) {
|
|
4747
|
+
const query = buildProposalCountQuery(filters);
|
|
4748
|
+
const row = this.db.prepare(query.sql).get(...query.params);
|
|
4749
|
+
return isRecord2(row) ? Number(row.count ?? 0) : 0;
|
|
4750
|
+
}
|
|
4560
4751
|
listEvidenceBundles(filters = {}) {
|
|
4561
4752
|
const query = buildEvidenceQuery(filters);
|
|
4562
4753
|
const rows = this.db.prepare(query.sql).all(...query.params);
|
|
@@ -4583,6 +4774,19 @@ var ProposalStore = class {
|
|
|
4583
4774
|
const proposalId = replayId.startsWith(prefix) ? replayId.slice(prefix.length) : replayId;
|
|
4584
4775
|
return this.replay(proposalId);
|
|
4585
4776
|
}
|
|
4777
|
+
getStoredReplay(replayId) {
|
|
4778
|
+
const row = this.db.prepare("SELECT * FROM replay_records WHERE replay_id = ?").get(replayId);
|
|
4779
|
+
return rowToStoredReplay(row);
|
|
4780
|
+
}
|
|
4781
|
+
getStoredReplayForProposal(proposalId) {
|
|
4782
|
+
const row = this.db.prepare(`
|
|
4783
|
+
SELECT * FROM replay_records
|
|
4784
|
+
WHERE proposal_id = ?
|
|
4785
|
+
ORDER BY created_at DESC, replay_id DESC
|
|
4786
|
+
LIMIT 1
|
|
4787
|
+
`).get(proposalId);
|
|
4788
|
+
return rowToStoredReplay(row);
|
|
4789
|
+
}
|
|
4586
4790
|
proposalIdForEvidence(evidenceBundleId) {
|
|
4587
4791
|
const evidence = this.getEvidenceBundle(evidenceBundleId);
|
|
4588
4792
|
if (evidence?.proposal_id) return evidence.proposal_id;
|
|
@@ -4885,6 +5089,19 @@ var ProposalStore = class {
|
|
|
4885
5089
|
});
|
|
4886
5090
|
return job;
|
|
4887
5091
|
}
|
|
5092
|
+
getWritebackJob(writebackJobId) {
|
|
5093
|
+
return rowToWritebackJob(this.db.prepare("SELECT * FROM writeback_jobs WHERE writeback_job_id = ?").get(writebackJobId));
|
|
5094
|
+
}
|
|
5095
|
+
listWritebackJobs(options = {}) {
|
|
5096
|
+
const clauses = [];
|
|
5097
|
+
const values = [];
|
|
5098
|
+
if (options.proposal_id) {
|
|
5099
|
+
clauses.push("proposal_id = ?");
|
|
5100
|
+
values.push(options.proposal_id);
|
|
5101
|
+
}
|
|
5102
|
+
const limit = Math.min(Math.max(options.limit ?? 100, 1), 1e3);
|
|
5103
|
+
return this.db.prepare(`SELECT * FROM writeback_jobs${clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""} ORDER BY created_at ASC, writeback_job_id ASC LIMIT ?`).all(...values, limit).map(rowToWritebackJob).filter((job) => Boolean(job));
|
|
5104
|
+
}
|
|
4888
5105
|
claimWritebackIntent(jobInput, runnerId) {
|
|
4889
5106
|
const job = parseWritebackJob(jobInput);
|
|
4890
5107
|
const proposal = this.requireProposal(job.proposal_id);
|
|
@@ -6327,6 +6544,7 @@ var ProposalStore = class {
|
|
|
6327
6544
|
const highestRisk = comparisons.filter((item) => item.status === "disagreement" || item.status === "partial_agreement" || item.status === "invalid_or_unsafe_scope_attempt").sort(
|
|
6328
6545
|
(left, right) => (right.risk_score ?? 0) - (left.risk_score ?? 0) || (right.amount_value ?? 0) - (left.amount_value ?? 0) || left.case_id.localeCompare(right.case_id)
|
|
6329
6546
|
).slice(0, 10);
|
|
6547
|
+
const suggestedPolicies = suggestedShadowPolicies(comparisons);
|
|
6330
6548
|
return {
|
|
6331
6549
|
study,
|
|
6332
6550
|
total_tasks_observed: comparisons.length,
|
|
@@ -6345,7 +6563,8 @@ var ProposalStore = class {
|
|
|
6345
6563
|
by_capability: byCapability,
|
|
6346
6564
|
by_decision_reason: byDecisionReason,
|
|
6347
6565
|
highest_risk_disagreements: highestRisk,
|
|
6348
|
-
suggested_policies:
|
|
6566
|
+
suggested_policies: suggestedPolicies,
|
|
6567
|
+
trust_progression: shadowTrustProgression(comparisons, suggestedPolicies),
|
|
6349
6568
|
comparisons,
|
|
6350
6569
|
generated_at: latestIsoTimestamp([
|
|
6351
6570
|
study.updated_at,
|
|
@@ -6706,6 +6925,19 @@ function inWhere(column, values) {
|
|
|
6706
6925
|
};
|
|
6707
6926
|
}
|
|
6708
6927
|
function buildProposalQuery(filters) {
|
|
6928
|
+
const { clauses, params } = proposalQueryParts(filters);
|
|
6929
|
+
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
6930
|
+
return {
|
|
6931
|
+
sql: `SELECT * FROM proposals${where} ORDER BY created_at DESC, proposal_id DESC${filters.limit ? " LIMIT ?" : ""}`,
|
|
6932
|
+
params: filters.limit ? [...params, filters.limit] : params
|
|
6933
|
+
};
|
|
6934
|
+
}
|
|
6935
|
+
function buildProposalCountQuery(filters) {
|
|
6936
|
+
const { clauses, params } = proposalQueryParts(filters);
|
|
6937
|
+
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
|
|
6938
|
+
return { sql: `SELECT COUNT(*) AS count FROM proposals${where}`, params };
|
|
6939
|
+
}
|
|
6940
|
+
function proposalQueryParts(filters) {
|
|
6709
6941
|
const clauses = [];
|
|
6710
6942
|
const params = [];
|
|
6711
6943
|
addEqual(clauses, params, "proposal_id", filters.proposal);
|
|
@@ -6717,7 +6949,7 @@ function buildProposalQuery(filters) {
|
|
|
6717
6949
|
addEqual(clauses, params, "action", filters.capability ?? filters.action);
|
|
6718
6950
|
addObjectFilter(clauses, params, "business_object", "source_table", "object_id", filters.objectType, filters.objectId);
|
|
6719
6951
|
addTimeRange(clauses, params, "created_at", filters.from, filters.to);
|
|
6720
|
-
return
|
|
6952
|
+
return { clauses, params };
|
|
6721
6953
|
}
|
|
6722
6954
|
function buildEvidenceQuery(filters) {
|
|
6723
6955
|
const clauses = [];
|
|
@@ -7299,6 +7531,62 @@ function rowToReceipt(row) {
|
|
|
7299
7531
|
source_table: row.source_table == null ? void 0 : String(row.source_table)
|
|
7300
7532
|
};
|
|
7301
7533
|
}
|
|
7534
|
+
function rowToWritebackJob(row) {
|
|
7535
|
+
if (!isRecord2(row)) return void 0;
|
|
7536
|
+
let payload;
|
|
7537
|
+
try {
|
|
7538
|
+
payload = JSON.parse(String(row.job_json));
|
|
7539
|
+
} catch {
|
|
7540
|
+
throw new ProposalStoreError("WRITEBACK_JOB_CORRUPT", `writeback job ${String(row.writeback_job_id)} payload is not valid JSON`);
|
|
7541
|
+
}
|
|
7542
|
+
if (!isRecord2(payload)) {
|
|
7543
|
+
throw new ProposalStoreError("WRITEBACK_JOB_CORRUPT", `writeback job ${String(row.writeback_job_id)} payload is not an object`);
|
|
7544
|
+
}
|
|
7545
|
+
const handler = payload.schema_version === "synapsor.handler-writeback.v1";
|
|
7546
|
+
let normalizedJob;
|
|
7547
|
+
if (!handler) {
|
|
7548
|
+
try {
|
|
7549
|
+
normalizedJob = parseWritebackJob(payload);
|
|
7550
|
+
} catch {
|
|
7551
|
+
throw new ProposalStoreError("WRITEBACK_JOB_CORRUPT", `writeback job ${String(row.writeback_job_id)} payload is not a supported writeback protocol`);
|
|
7552
|
+
}
|
|
7553
|
+
}
|
|
7554
|
+
const payloadJobId = handler ? payload.writeback_job_id : normalizedJob?.job_id;
|
|
7555
|
+
const payloadProposalId = handler ? payload.proposal_id : normalizedJob?.proposal_id;
|
|
7556
|
+
const payloadProposalHash = handler ? payload.proposal_hash : normalizedJob?.approval_id;
|
|
7557
|
+
if (payloadJobId !== String(row.writeback_job_id) || payloadProposalId !== String(row.proposal_id) || payloadProposalHash !== String(row.proposal_hash)) {
|
|
7558
|
+
throw new ProposalStoreError("WRITEBACK_JOB_CORRUPT", `writeback job ${String(row.writeback_job_id)} index fields do not match its payload`);
|
|
7559
|
+
}
|
|
7560
|
+
return {
|
|
7561
|
+
writeback_job_id: String(row.writeback_job_id),
|
|
7562
|
+
proposal_id: String(row.proposal_id),
|
|
7563
|
+
proposal_hash: String(row.proposal_hash),
|
|
7564
|
+
status: String(row.status),
|
|
7565
|
+
kind: handler ? "app_handler" : "direct_sql",
|
|
7566
|
+
payload,
|
|
7567
|
+
...normalizedJob ? { normalized_job: normalizedJob } : {},
|
|
7568
|
+
created_at: String(row.created_at),
|
|
7569
|
+
updated_at: String(row.updated_at)
|
|
7570
|
+
};
|
|
7571
|
+
}
|
|
7572
|
+
function rowToStoredReplay(row) {
|
|
7573
|
+
if (!isRecord2(row)) return void 0;
|
|
7574
|
+
let payload;
|
|
7575
|
+
try {
|
|
7576
|
+
payload = JSON.parse(String(row.payload_json));
|
|
7577
|
+
} catch {
|
|
7578
|
+
throw new ProposalStoreError("REPLAY_RECORD_CORRUPT", `replay ${String(row.replay_id)} payload is not valid JSON`);
|
|
7579
|
+
}
|
|
7580
|
+
if (!isRecord2(payload) || !isRecord2(payload.proposal)) {
|
|
7581
|
+
throw new ProposalStoreError("REPLAY_RECORD_CORRUPT", `replay ${String(row.replay_id)} payload is not a supported replay record`);
|
|
7582
|
+
}
|
|
7583
|
+
const replayId = String(row.replay_id);
|
|
7584
|
+
const proposalId = String(row.proposal_id);
|
|
7585
|
+
if (payload.replay_id !== replayId || payload.proposal.proposal_id !== proposalId || !Array.isArray(payload.approvals) || !Array.isArray(payload.events) || !Array.isArray(payload.receipts) || !Array.isArray(payload.query_audit) || !Array.isArray(payload.evidence) || typeof payload.generated_at !== "string") {
|
|
7586
|
+
throw new ProposalStoreError("REPLAY_RECORD_CORRUPT", `replay ${replayId} index fields do not match its payload`);
|
|
7587
|
+
}
|
|
7588
|
+
return payload;
|
|
7589
|
+
}
|
|
7302
7590
|
function rowToWritebackIntent(row) {
|
|
7303
7591
|
if (!isRecord2(row)) return void 0;
|
|
7304
7592
|
const status = String(row.status);
|
|
@@ -7829,6 +8117,32 @@ function suggestedShadowPolicies(comparisons) {
|
|
|
7829
8117
|
}
|
|
7830
8118
|
return suggestions;
|
|
7831
8119
|
}
|
|
8120
|
+
function shadowTrustProgression(comparisons, suggestions) {
|
|
8121
|
+
const outcomes = comparisons.filter((item) => item.outcome !== void 0).length;
|
|
8122
|
+
const comparable = comparisons.filter((item) => item.comparable).length;
|
|
8123
|
+
const exact = comparisons.filter((item) => item.status === "exact_agreement").length;
|
|
8124
|
+
const currentStage = comparisons.length === 0 ? "observe" : outcomes === 0 ? "compare" : suggestions.length === 0 ? "manual_review" : "suggested_bounded_policy";
|
|
8125
|
+
const stageOrder = ["observe", "compare", "manual_review", "suggested_bounded_policy"];
|
|
8126
|
+
const currentIndex = stageOrder.indexOf(currentStage);
|
|
8127
|
+
const details = {
|
|
8128
|
+
observe: `${comparisons.length} task${comparisons.length === 1 ? "" : "s"} observed without source mutation.`,
|
|
8129
|
+
compare: `${outcomes} authoritative outcome${outcomes === 1 ? "" : "s"}; ${comparisons.length - outcomes} unmatched.`,
|
|
8130
|
+
manual_review: `${comparable} comparable task${comparable === 1 ? "" : "s"}; ${exact} exact agreement${exact === 1 ? "" : "s"}. At least 5 exact numeric examples are required before a bounded-policy suggestion.`,
|
|
8131
|
+
suggested_bounded_policy: suggestions.length > 0 ? `${suggestions.length} inactive bounded-policy suggestion${suggestions.length === 1 ? "" : "s"}; a human must review and activate any contract change separately.` : "No policy suggestion is available."
|
|
8132
|
+
};
|
|
8133
|
+
const labels = ["Observe", "Compare", "Manual review", "Suggested bounded policy"];
|
|
8134
|
+
return {
|
|
8135
|
+
current_stage: currentStage,
|
|
8136
|
+
minimum_policy_sample_size: 5,
|
|
8137
|
+
insufficient_sample_size: suggestions.length === 0,
|
|
8138
|
+
stages: stageOrder.map((stage, index) => ({
|
|
8139
|
+
name: labels[index],
|
|
8140
|
+
status: index < currentIndex ? "complete" : index === currentIndex ? "current" : "locked",
|
|
8141
|
+
detail: details[stage]
|
|
8142
|
+
})),
|
|
8143
|
+
automatic_activation: false
|
|
8144
|
+
};
|
|
8145
|
+
}
|
|
7832
8146
|
function safeSqliteFailure(_error, fallback) {
|
|
7833
8147
|
return fallback;
|
|
7834
8148
|
}
|
|
@@ -10811,8 +11125,30 @@ async function boundedJwksFetch(resource, init, maxBytes) {
|
|
|
10811
11125
|
body.set(chunk, offset);
|
|
10812
11126
|
offset += chunk.byteLength;
|
|
10813
11127
|
}
|
|
11128
|
+
if (response.ok) validatePublicJwks(body);
|
|
10814
11129
|
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
10815
11130
|
}
|
|
11131
|
+
function validatePublicJwks(body) {
|
|
11132
|
+
let parsed;
|
|
11133
|
+
try {
|
|
11134
|
+
parsed = JSON.parse(new TextDecoder().decode(body));
|
|
11135
|
+
} catch {
|
|
11136
|
+
throw new Error("JWKS response is not valid JSON");
|
|
11137
|
+
}
|
|
11138
|
+
if (!isRecord4(parsed) || !Array.isArray(parsed.keys) || parsed.keys.length > 128) {
|
|
11139
|
+
throw new Error("JWKS response must contain a bounded keys array");
|
|
11140
|
+
}
|
|
11141
|
+
const privateMembers = /* @__PURE__ */ new Set(["d", "p", "q", "dp", "dq", "qi", "oth", "k"]);
|
|
11142
|
+
for (const key of parsed.keys) {
|
|
11143
|
+
if (!isRecord4(key) || typeof key.kty !== "string") throw new Error("JWKS contains an invalid key");
|
|
11144
|
+
if (key.kty === "oct" || Object.keys(key).some((member) => privateMembers.has(member))) {
|
|
11145
|
+
throw new Error("JWKS must contain public verification keys only");
|
|
11146
|
+
}
|
|
11147
|
+
}
|
|
11148
|
+
}
|
|
11149
|
+
function isRecord4(value) {
|
|
11150
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11151
|
+
}
|
|
10816
11152
|
function trimmedEnv(env, name) {
|
|
10817
11153
|
const value = env[name]?.trim();
|
|
10818
11154
|
return value || void 0;
|
|
@@ -11355,8 +11691,8 @@ function loadCloudLinkedConnection(config, env = process.env) {
|
|
|
11355
11691
|
} catch (error) {
|
|
11356
11692
|
throw new McpRuntimeError("CLOUD_CONNECTION_INVALID", `Unable to read the reviewed Cloud connection file: ${error instanceof Error ? error.message : String(error)}`);
|
|
11357
11693
|
}
|
|
11358
|
-
const root =
|
|
11359
|
-
const cloud =
|
|
11694
|
+
const root = isRecord5(parsed) ? parsed : {};
|
|
11695
|
+
const cloud = isRecord5(root.cloud) ? root.cloud : void 0;
|
|
11360
11696
|
if (!cloud) throw new McpRuntimeError("CLOUD_CONNECTION_INVALID", "Cloud connection file must contain a cloud object.");
|
|
11361
11697
|
const baseUrlEnv = nonEmptyString(cloud.base_url_env) ?? "SYNAPSOR_CLOUD_BASE_URL";
|
|
11362
11698
|
const runnerTokenEnv = nonEmptyString(cloud.runner_token_env) ?? "SYNAPSOR_RUNNER_TOKEN";
|
|
@@ -11547,13 +11883,13 @@ function cloudSafeChangeSet(changeSet) {
|
|
|
11547
11883
|
}
|
|
11548
11884
|
function stripCloudPrincipalColumn(changeSet, column) {
|
|
11549
11885
|
const strip = (value) => {
|
|
11550
|
-
if (
|
|
11886
|
+
if (isRecord5(value)) delete value[column];
|
|
11551
11887
|
};
|
|
11552
11888
|
strip(changeSet.before);
|
|
11553
11889
|
strip(changeSet.after);
|
|
11554
|
-
if ("frozen_set" in changeSet &&
|
|
11890
|
+
if ("frozen_set" in changeSet && isRecord5(changeSet.frozen_set) && Array.isArray(changeSet.frozen_set.members)) {
|
|
11555
11891
|
for (const member of changeSet.frozen_set.members) {
|
|
11556
|
-
if (!
|
|
11892
|
+
if (!isRecord5(member)) continue;
|
|
11557
11893
|
strip(member.before);
|
|
11558
11894
|
strip(member.after);
|
|
11559
11895
|
}
|
|
@@ -11740,7 +12076,7 @@ var CloudLinkedSynchronizer = class {
|
|
|
11740
12076
|
if (item.kind === "proposal") return this.client.submitProposal(item.payload);
|
|
11741
12077
|
if (item.kind === "activity") return this.client.submitActivity(item.payload);
|
|
11742
12078
|
if (item.kind === "result") {
|
|
11743
|
-
const result =
|
|
12079
|
+
const result = isRecord5(item.payload.result) ? item.payload.result : void 0;
|
|
11744
12080
|
const leaseId = nonEmptyString(item.payload.lease_id);
|
|
11745
12081
|
if (!result || !leaseId) throw new McpRuntimeError("CLOUD_RESULT_OUTBOX_INVALID", "Cloud result outbox entry is missing a result or lease identity.");
|
|
11746
12082
|
return this.client.result(result, leaseId);
|
|
@@ -11749,9 +12085,9 @@ var CloudLinkedSynchronizer = class {
|
|
|
11749
12085
|
}
|
|
11750
12086
|
};
|
|
11751
12087
|
function cloudGovernanceStatusPayload(response) {
|
|
11752
|
-
const decision =
|
|
11753
|
-
const job =
|
|
11754
|
-
const result =
|
|
12088
|
+
const decision = isRecord5(response.decision) ? response.decision : void 0;
|
|
12089
|
+
const job = isRecord5(response.job) ? response.job : void 0;
|
|
12090
|
+
const result = isRecord5(response.result) ? response.result : void 0;
|
|
11755
12091
|
const actor = nonEmptyString(decision?.actor);
|
|
11756
12092
|
return JSON.parse(JSON.stringify({
|
|
11757
12093
|
contract_id: nonEmptyString(response.contract_id),
|
|
@@ -11844,9 +12180,17 @@ function mergeContractIntoRuntimeConfig(config, contract, origin, seenCapabiliti
|
|
|
11844
12180
|
}
|
|
11845
12181
|
}
|
|
11846
12182
|
function runtimeContextFromSpec(context) {
|
|
12183
|
+
const unsupportedSessionBinding = context.bindings.find((binding) => binding.source === "session");
|
|
12184
|
+
if (unsupportedSessionBinding) {
|
|
12185
|
+
throw new Error(
|
|
12186
|
+
`SESSION_BINDING_UNSUPPORTED: context ${context.name} binding ${unsupportedSessionBinding.name} uses canonical SESSION source, but Synapsor Runner has no generic web-session trust provider. Use ENVIRONMENT for local stdio, HTTP_CLAIM for verified HTTP JWT claims, or CLOUD_SESSION for verified Cloud-linked identity.`
|
|
12187
|
+
);
|
|
12188
|
+
}
|
|
11847
12189
|
const tenantBinding = context.bindings.find((binding) => binding.name === context.tenant_binding) ?? context.bindings.find((binding) => binding.name === "tenant_id");
|
|
11848
12190
|
const principalBinding = context.bindings.find((binding) => binding.name === context.principal_binding) ?? context.bindings.find((binding) => binding.name === "principal");
|
|
11849
|
-
const provider = context.bindings.some((binding) => binding.source === "environment") ? "environment" : context.bindings.some((binding) => binding.source === "cloud_session") ? "cloud_session" : context.bindings.some((binding) => binding.source === "http_claim") ? "http_claims" : context.bindings.some((binding) => binding.source === "static_dev") ? "static_dev" :
|
|
12191
|
+
const provider = context.bindings.some((binding) => binding.source === "environment") ? "environment" : context.bindings.some((binding) => binding.source === "cloud_session") ? "cloud_session" : context.bindings.some((binding) => binding.source === "http_claim") ? "http_claims" : context.bindings.some((binding) => binding.source === "static_dev") ? "static_dev" : (() => {
|
|
12192
|
+
throw new Error(`TRUSTED_CONTEXT_BINDING_UNSUPPORTED: context ${context.name} has no binding source supported by Synapsor Runner.`);
|
|
12193
|
+
})();
|
|
11850
12194
|
return {
|
|
11851
12195
|
provider,
|
|
11852
12196
|
tenant_binding: context.tenant_binding,
|
|
@@ -12258,24 +12602,253 @@ async function serveStdio(options = {}) {
|
|
|
12258
12602
|
process.once("SIGTERM", close);
|
|
12259
12603
|
});
|
|
12260
12604
|
}
|
|
12605
|
+
function resolveHttpSecurity(config, options, host, env, usesSessionAuth) {
|
|
12606
|
+
const configured = config.http_security;
|
|
12607
|
+
const loopback = isLoopbackHost(host);
|
|
12608
|
+
if (options.devNoAuth && !loopback) {
|
|
12609
|
+
throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
|
|
12610
|
+
}
|
|
12611
|
+
const deployment = configured?.deployment ?? (loopback ? "loopback" : void 0);
|
|
12612
|
+
if (!deployment) {
|
|
12613
|
+
throw new McpRuntimeError(
|
|
12614
|
+
"HTTP_REMOTE_DEPLOYMENT_REQUIRED",
|
|
12615
|
+
"A non-loopback listener requires http_security.deployment single_tenant or shared."
|
|
12616
|
+
);
|
|
12617
|
+
}
|
|
12618
|
+
if (!loopback && deployment === "loopback") {
|
|
12619
|
+
throw new McpRuntimeError("HTTP_LOOPBACK_PROFILE_REMOTE", "http_security.deployment loopback cannot bind a non-loopback listener.");
|
|
12620
|
+
}
|
|
12621
|
+
if (deployment === "shared" && !usesSessionAuth) {
|
|
12622
|
+
throw new McpRuntimeError("HTTP_SHARED_SESSION_AUTH_REQUIRED", "Shared HTTP deployment requires signed per-session http_claims identity.");
|
|
12623
|
+
}
|
|
12624
|
+
if (deployment !== "shared" && usesSessionAuth && !loopback) {
|
|
12625
|
+
throw new McpRuntimeError("HTTP_SHARED_DEPLOYMENT_REQUIRED", "Remote http_claims identity requires http_security.deployment shared.");
|
|
12626
|
+
}
|
|
12627
|
+
const optionChannels = [
|
|
12628
|
+
options.trustedTlsProxy ? "trusted_tls_proxy" : void 0,
|
|
12629
|
+
options.unsafeAllowCleartextHttp ? "insecure_http_break_glass" : void 0
|
|
12630
|
+
].filter((value) => Boolean(value));
|
|
12631
|
+
if (optionChannels.length > 1) {
|
|
12632
|
+
throw new McpRuntimeError("HTTP_CHANNEL_CONFLICT", "Choose only one of trusted TLS proxy or unsafe cleartext break-glass mode.");
|
|
12633
|
+
}
|
|
12634
|
+
const requestedChannel = optionChannels[0] ?? configured?.channel;
|
|
12635
|
+
if (options.tls && requestedChannel && requestedChannel !== "direct_tls") {
|
|
12636
|
+
throw new McpRuntimeError("HTTP_CHANNEL_CONFLICT", "Runner-owned TLS cannot be combined with trusted-proxy or insecure-cleartext channel declarations.");
|
|
12637
|
+
}
|
|
12638
|
+
let channel;
|
|
12639
|
+
if (options.tls) channel = "direct_tls";
|
|
12640
|
+
else if (requestedChannel === "direct_tls") {
|
|
12641
|
+
throw new McpRuntimeError("HTTP_TLS_MATERIAL_REQUIRED", "http_security.channel direct_tls requires Runner TLS certificate and key material.");
|
|
12642
|
+
} else if (requestedChannel === "trusted_tls_proxy") channel = "trusted_tls_proxy";
|
|
12643
|
+
else if (requestedChannel === "insecure_http_break_glass") channel = "insecure_http_break_glass";
|
|
12644
|
+
else if (loopback) channel = "loopback_cleartext";
|
|
12645
|
+
else {
|
|
12646
|
+
throw new McpRuntimeError(
|
|
12647
|
+
"HTTP_REMOTE_CLEARTEXT_REFUSED",
|
|
12648
|
+
"Refusing non-loopback cleartext HTTP. Configure Runner-owned TLS, an explicit trusted TLS proxy, or --unsafe-allow-cleartext-http break glass."
|
|
12649
|
+
);
|
|
12650
|
+
}
|
|
12651
|
+
if (options.devNoAuth && channel === "insecure_http_break_glass") {
|
|
12652
|
+
throw new McpRuntimeError("HTTP_BREAK_GLASS_AUTH_REQUIRED", "Unsafe cleartext break-glass mode never disables authentication.");
|
|
12653
|
+
}
|
|
12654
|
+
if (options.devNoAuth && deployment !== "loopback") {
|
|
12655
|
+
throw new McpRuntimeError("HTTP_DEV_NO_AUTH_PROFILE_INVALID", "--dev-no-auth is valid only for a loopback development deployment.");
|
|
12656
|
+
}
|
|
12657
|
+
const activeTokenEnv = options.authTokenEnv ?? configured?.static_token?.active_env ?? "SYNAPSOR_RUNNER_HTTP_TOKEN";
|
|
12658
|
+
const previousTokenEnv = options.previousAuthTokenEnv ?? configured?.static_token?.previous_env;
|
|
12659
|
+
if (previousTokenEnv && previousTokenEnv === activeTokenEnv) {
|
|
12660
|
+
throw new McpRuntimeError("HTTP_TOKEN_ENV_REUSED", "Active and previous HTTP token environment variables must be different.");
|
|
12661
|
+
}
|
|
12662
|
+
const activeToken = options.devNoAuth || usesSessionAuth ? void 0 : envValue(env, activeTokenEnv);
|
|
12663
|
+
const previousToken = options.devNoAuth || usesSessionAuth || !previousTokenEnv ? void 0 : envValue(env, previousTokenEnv);
|
|
12664
|
+
if (!options.devNoAuth && !usesSessionAuth && !activeToken) {
|
|
12665
|
+
throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${activeTokenEnv} is not set. HTTP MCP requires bearer auth by default.`);
|
|
12666
|
+
}
|
|
12667
|
+
if (previousTokenEnv && !usesSessionAuth && !options.devNoAuth && !previousToken) {
|
|
12668
|
+
throw new McpRuntimeError("HTTP_PREVIOUS_AUTH_TOKEN_MISSING", `${previousTokenEnv} is configured for rotation but is not set.`);
|
|
12669
|
+
}
|
|
12670
|
+
if (activeToken && previousToken && constantTimeTokenEquals(activeToken, previousToken)) {
|
|
12671
|
+
throw new McpRuntimeError("HTTP_TOKEN_ROTATION_DUPLICATE", "Active and previous HTTP endpoint tokens must differ.");
|
|
12672
|
+
}
|
|
12673
|
+
const weakStaticToken = Boolean(activeToken && !strongOpaqueToken(activeToken)) || Boolean(previousToken && !strongOpaqueToken(previousToken));
|
|
12674
|
+
if (!loopback && !usesSessionAuth && weakStaticToken) {
|
|
12675
|
+
throw new McpRuntimeError("HTTP_AUTH_TOKEN_WEAK", "Non-loopback static endpoint tokens must contain at least 32 bytes of high-entropy secret material.");
|
|
12676
|
+
}
|
|
12677
|
+
const configuredOrigins = configured?.allowed_origins ?? [];
|
|
12678
|
+
const allowedOrigins = new Set(configuredOrigins);
|
|
12679
|
+
if (options.corsOrigin) {
|
|
12680
|
+
if (!isExactHttpOrigin2(options.corsOrigin)) {
|
|
12681
|
+
throw new McpRuntimeError("HTTP_CORS_ORIGIN_INVALID", "--cors-origin must be one exact HTTP(S) origin; wildcards, paths, credentials, query, and fragments are forbidden.");
|
|
12682
|
+
}
|
|
12683
|
+
allowedOrigins.add(options.corsOrigin);
|
|
12684
|
+
}
|
|
12685
|
+
const allowedHosts = configured?.allowed_hosts?.map((value) => value.toLowerCase()) ?? defaultAllowedHosts(host);
|
|
12686
|
+
if (!loopback && allowedHosts.length === 0) {
|
|
12687
|
+
throw new McpRuntimeError("HTTP_ALLOWED_HOSTS_REQUIRED", "Non-loopback HTTP requires http_security.allowed_hosts with exact public/direct Host authorities.");
|
|
12688
|
+
}
|
|
12689
|
+
const rawLimits = configured?.limits;
|
|
12690
|
+
const limits = {
|
|
12691
|
+
maxRequestBytes: rawLimits?.max_request_bytes ?? 1048576,
|
|
12692
|
+
maxHeaderBytes: rawLimits?.max_header_bytes ?? 16384,
|
|
12693
|
+
maxSessions: rawLimits?.max_sessions ?? 1024,
|
|
12694
|
+
sessionIdleTimeoutMs: (rawLimits?.session_idle_timeout_seconds ?? 900) * 1e3,
|
|
12695
|
+
requestTimeoutMs: rawLimits?.request_timeout_ms ?? 3e4,
|
|
12696
|
+
headersTimeoutMs: rawLimits?.headers_timeout_ms ?? 1e4,
|
|
12697
|
+
keepAliveTimeoutMs: rawLimits?.keep_alive_timeout_ms ?? 5e3,
|
|
12698
|
+
maxConnections: rawLimits?.max_connections ?? 2048
|
|
12699
|
+
};
|
|
12700
|
+
const oauth = configured?.oauth_resource ? resolveOauthResource(configured.oauth_resource) : void 0;
|
|
12701
|
+
if (deployment === "shared") {
|
|
12702
|
+
if (!oauth) throw new McpRuntimeError("HTTP_OAUTH_RESOURCE_REQUIRED", "Shared HTTP deployment requires RFC 9728 protected-resource metadata.");
|
|
12703
|
+
const auth = config.session_auth;
|
|
12704
|
+
if (!auth?.issuer || !auth.audience) {
|
|
12705
|
+
throw new McpRuntimeError("HTTP_JWT_ISSUER_AUDIENCE_REQUIRED", "Shared HTTP deployment requires exact session_auth issuer and audience/resource.");
|
|
12706
|
+
}
|
|
12707
|
+
if (auth.audience !== configured?.oauth_resource?.resource) {
|
|
12708
|
+
throw new McpRuntimeError("HTTP_RESOURCE_AUDIENCE_MISMATCH", "session_auth.audience must exactly match http_security.oauth_resource.resource.");
|
|
12709
|
+
}
|
|
12710
|
+
}
|
|
12711
|
+
return {
|
|
12712
|
+
deployment,
|
|
12713
|
+
channel,
|
|
12714
|
+
activeToken,
|
|
12715
|
+
previousToken,
|
|
12716
|
+
activeTokenEnv,
|
|
12717
|
+
previousTokenEnv,
|
|
12718
|
+
weakStaticToken,
|
|
12719
|
+
allowedOrigins,
|
|
12720
|
+
allowedHosts,
|
|
12721
|
+
limits,
|
|
12722
|
+
oauth
|
|
12723
|
+
};
|
|
12724
|
+
}
|
|
12725
|
+
function resolveOauthResource(input) {
|
|
12726
|
+
const resource = new URL(input.resource);
|
|
12727
|
+
const pathname = resource.pathname === "/" ? "" : resource.pathname.replace(/\/$/, "");
|
|
12728
|
+
const metadataPath = `/.well-known/oauth-protected-resource${pathname}`;
|
|
12729
|
+
const metadataUrl = new URL(metadataPath || "/.well-known/oauth-protected-resource", resource.origin).toString();
|
|
12730
|
+
const metadata = OAuthProtectedResourceMetadataSchema.parse({
|
|
12731
|
+
resource: input.resource,
|
|
12732
|
+
authorization_servers: input.authorization_servers,
|
|
12733
|
+
...input.scopes_supported ? { scopes_supported: input.scopes_supported } : {},
|
|
12734
|
+
bearer_methods_supported: ["header"],
|
|
12735
|
+
...input.resource_name ? { resource_name: input.resource_name } : {},
|
|
12736
|
+
...input.resource_documentation ? { resource_documentation: input.resource_documentation } : {}
|
|
12737
|
+
});
|
|
12738
|
+
return { metadata, metadataUrl, metadataPath, requiredScopes: input.required_scopes ?? [] };
|
|
12739
|
+
}
|
|
12740
|
+
function defaultAllowedHosts(host) {
|
|
12741
|
+
if (!isLoopbackHost(host)) {
|
|
12742
|
+
return host === "0.0.0.0" || host === "::" ? [] : [host.toLowerCase()];
|
|
12743
|
+
}
|
|
12744
|
+
return ["localhost", "127.0.0.1", "[::1]", host.toLowerCase()];
|
|
12745
|
+
}
|
|
12746
|
+
function applyHttpServerLimits(server, limits) {
|
|
12747
|
+
server.requestTimeout = limits.requestTimeoutMs;
|
|
12748
|
+
server.headersTimeout = limits.headersTimeoutMs;
|
|
12749
|
+
server.keepAliveTimeout = limits.keepAliveTimeoutMs;
|
|
12750
|
+
server.maxConnections = limits.maxConnections;
|
|
12751
|
+
}
|
|
12752
|
+
function validateTlsMaterial(tls) {
|
|
12753
|
+
if (!tls) return;
|
|
12754
|
+
try {
|
|
12755
|
+
createSecureContext({ cert: tls.cert, key: tls.key, ca: tls.ca });
|
|
12756
|
+
} catch {
|
|
12757
|
+
throw new McpRuntimeError("HTTP_TLS_MATERIAL_INVALID", "HTTP TLS certificate, private key, or CA material is invalid.");
|
|
12758
|
+
}
|
|
12759
|
+
}
|
|
12760
|
+
function validateHttpRequestSecurity(request, response, security) {
|
|
12761
|
+
if ((request.url?.length ?? 0) > 8192) {
|
|
12762
|
+
writeJson(response, 414, { ok: false, error: "uri_too_long" });
|
|
12763
|
+
return false;
|
|
12764
|
+
}
|
|
12765
|
+
const host = headerValue(request.headers.host);
|
|
12766
|
+
if (!host || !hostAllowed(host, security.allowedHosts)) {
|
|
12767
|
+
writeJson(response, 403, { ok: false, error: "host_forbidden" });
|
|
12768
|
+
return false;
|
|
12769
|
+
}
|
|
12770
|
+
const origin = headerValue(request.headers.origin);
|
|
12771
|
+
if (origin && !security.allowedOrigins.has(origin)) {
|
|
12772
|
+
writeJson(response, 403, { ok: false, error: "origin_forbidden" });
|
|
12773
|
+
return false;
|
|
12774
|
+
}
|
|
12775
|
+
setHttpSecurityHeaders(response);
|
|
12776
|
+
if (origin) setCorsHeaders(response, origin);
|
|
12777
|
+
return true;
|
|
12778
|
+
}
|
|
12779
|
+
function hostAllowed(rawHost, allowedHosts) {
|
|
12780
|
+
const actual = parseHostAuthority(rawHost);
|
|
12781
|
+
if (!actual) return false;
|
|
12782
|
+
return allowedHosts.some((allowed) => {
|
|
12783
|
+
const expected = parseHostAuthority(allowed);
|
|
12784
|
+
if (!expected || expected.hostname !== actual.hostname) return false;
|
|
12785
|
+
return expected.port ? expected.port === actual.port : true;
|
|
12786
|
+
});
|
|
12787
|
+
}
|
|
12788
|
+
function parseHostAuthority(value) {
|
|
12789
|
+
if (!value || value !== value.trim() || /[\s,/?#\\]/.test(value)) return void 0;
|
|
12790
|
+
try {
|
|
12791
|
+
const parsed = new URL(`http://${value}`);
|
|
12792
|
+
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return void 0;
|
|
12793
|
+
return { hostname: parsed.hostname.toLowerCase(), port: parsed.port };
|
|
12794
|
+
} catch {
|
|
12795
|
+
return void 0;
|
|
12796
|
+
}
|
|
12797
|
+
}
|
|
12798
|
+
function isExactHttpOrigin2(value) {
|
|
12799
|
+
if (value === "*" || value === "null") return false;
|
|
12800
|
+
try {
|
|
12801
|
+
const origin = new URL(value);
|
|
12802
|
+
return (origin.protocol === "http:" || origin.protocol === "https:") && !origin.username && !origin.password && origin.pathname === "/" && !origin.search && !origin.hash && origin.origin === value;
|
|
12803
|
+
} catch {
|
|
12804
|
+
return false;
|
|
12805
|
+
}
|
|
12806
|
+
}
|
|
12807
|
+
function setHttpSecurityHeaders(response) {
|
|
12808
|
+
response.setHeader("cache-control", "no-store");
|
|
12809
|
+
response.setHeader("x-content-type-options", "nosniff");
|
|
12810
|
+
response.setHeader("referrer-policy", "no-referrer");
|
|
12811
|
+
}
|
|
12812
|
+
function strongOpaqueToken(token) {
|
|
12813
|
+
if (Buffer.byteLength(token, "utf8") < 32) return false;
|
|
12814
|
+
if (new Set(token).size < 12) return false;
|
|
12815
|
+
return !/^(.)\1+$/.test(token) && !/(?:password|secret|token|changeme|example|development)/i.test(token);
|
|
12816
|
+
}
|
|
12817
|
+
function httpAuthChallenge(security, insufficientScope = false) {
|
|
12818
|
+
const parts = ["Bearer"];
|
|
12819
|
+
if (insufficientScope) parts.push('error="insufficient_scope"');
|
|
12820
|
+
if (security.oauth) {
|
|
12821
|
+
parts.push(`resource_metadata="${security.oauth.metadataUrl}"`);
|
|
12822
|
+
if (security.oauth.requiredScopes.length) parts.push(`scope="${security.oauth.requiredScopes.join(" ")}"`);
|
|
12823
|
+
}
|
|
12824
|
+
return parts.join(" ");
|
|
12825
|
+
}
|
|
12826
|
+
function maybeServeOauthMetadata(request, response, security, pathname) {
|
|
12827
|
+
if (!security.oauth || request.method !== "GET") return false;
|
|
12828
|
+
if (pathname !== security.oauth.metadataPath && pathname !== "/.well-known/oauth-protected-resource") return false;
|
|
12829
|
+
writeJson(response, 200, security.oauth.metadata);
|
|
12830
|
+
return true;
|
|
12831
|
+
}
|
|
12832
|
+
function writeAuthenticationFailure(response, security, status, error) {
|
|
12833
|
+
response.setHeader("www-authenticate", httpAuthChallenge(security, status === 403));
|
|
12834
|
+
writeJson(response, status, { ok: false, error });
|
|
12835
|
+
}
|
|
12261
12836
|
async function startHttpMcpServer(options = {}) {
|
|
12262
12837
|
const host = options.host ?? "127.0.0.1";
|
|
12263
12838
|
const port = options.port ?? 8765;
|
|
12264
|
-
const authTokenEnv = options.authTokenEnv ?? "SYNAPSOR_RUNNER_HTTP_TOKEN";
|
|
12265
12839
|
const env = options.env ?? process.env;
|
|
12266
12840
|
const devNoAuth = options.devNoAuth === true;
|
|
12267
12841
|
const config = resolveRuntimeConfig(options.config ?? loadRuntimeConfigFromFile(options.configPath));
|
|
12268
|
-
|
|
12269
|
-
if (devNoAuth && !isLoopbackHost(host)) {
|
|
12270
|
-
throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
|
|
12271
|
-
}
|
|
12842
|
+
assertValidRunnerCapabilityConfig(config);
|
|
12272
12843
|
if (configUsesHttpClaims(config)) {
|
|
12273
12844
|
throw new McpRuntimeError("HTTP_CLAIMS_REQUIRES_STREAMABLE", "http_claims trusted context requires spec MCP Streamable HTTP sessions; the legacy JSON-RPC bridge cannot bind per-session context.");
|
|
12274
12845
|
}
|
|
12275
|
-
const
|
|
12276
|
-
|
|
12277
|
-
|
|
12846
|
+
const security = resolveHttpSecurity(config, options, host, env, false);
|
|
12847
|
+
const metricsAccess = resolveMetricsEndpointAccess(config, env, host);
|
|
12848
|
+
if (options.tls?.requestClientCert && !options.tls.ca) {
|
|
12849
|
+
throw new McpRuntimeError("MTLS_CA_REQUIRED", "HTTP mTLS requires a CA bundle when client certificates are required.");
|
|
12278
12850
|
}
|
|
12851
|
+
validateTlsMaterial(options.tls);
|
|
12279
12852
|
const cloudTools = config.mode === "cloud" ? await fetchCloudToolMetadata(config, env) : void 0;
|
|
12280
12853
|
if (options.readRow && Object.values(config.sources ?? {}).some((source) => source.database_scope?.mode === "postgres_rls")) {
|
|
12281
12854
|
throw new McpRuntimeError("POSTGRES_RLS_CUSTOM_READER_UNVERIFIED", "Hardened postgres_rls mode requires Runner's verified PostgreSQL reader; a custom readRow cannot be attested by the stock server.");
|
|
@@ -12290,19 +12863,27 @@ async function startHttpMcpServer(options = {}) {
|
|
|
12290
12863
|
cloudTools
|
|
12291
12864
|
});
|
|
12292
12865
|
const readinessCheck = options.readinessCheck ?? (() => checkRunnerReadiness(config, env));
|
|
12293
|
-
const
|
|
12866
|
+
const requestHandler = (request, response) => {
|
|
12294
12867
|
void handleHttpMcpRequest({
|
|
12295
12868
|
request,
|
|
12296
12869
|
response,
|
|
12297
12870
|
runtime,
|
|
12298
|
-
authToken,
|
|
12299
12871
|
devNoAuth,
|
|
12300
|
-
|
|
12872
|
+
security,
|
|
12301
12873
|
readinessCheck,
|
|
12302
12874
|
metricsAccess,
|
|
12303
12875
|
metricsProvider: () => renderRuntimeMetrics(runtime.store, runtime.poolMetrics(), runtime.rateLimitMetrics(), readinessCheck)
|
|
12304
12876
|
});
|
|
12305
|
-
}
|
|
12877
|
+
};
|
|
12878
|
+
const server = options.tls ? createHttpsServer({
|
|
12879
|
+
cert: options.tls.cert,
|
|
12880
|
+
key: options.tls.key,
|
|
12881
|
+
ca: options.tls.ca,
|
|
12882
|
+
requestCert: options.tls.requestClientCert === true,
|
|
12883
|
+
rejectUnauthorized: options.tls.requestClientCert === true,
|
|
12884
|
+
maxHeaderSize: security.limits.maxHeaderBytes
|
|
12885
|
+
}, requestHandler) : createServer({ maxHeaderSize: security.limits.maxHeaderBytes }, requestHandler);
|
|
12886
|
+
applyHttpServerLimits(server, security.limits);
|
|
12306
12887
|
try {
|
|
12307
12888
|
await new Promise((resolve2, reject) => {
|
|
12308
12889
|
server.once("error", reject);
|
|
@@ -12318,13 +12899,19 @@ async function startHttpMcpServer(options = {}) {
|
|
|
12318
12899
|
const address = server.address();
|
|
12319
12900
|
const actualHost = address.address === "::" ? host : address.address;
|
|
12320
12901
|
const actualPort = address.port;
|
|
12321
|
-
const
|
|
12902
|
+
const scheme = options.tls ? "https" : "http";
|
|
12903
|
+
const url = `${scheme}://${actualHost}:${actualPort}/mcp`;
|
|
12322
12904
|
if (options.log !== false) {
|
|
12323
12905
|
const log = options.log ?? process.stderr;
|
|
12324
12906
|
log.write(`Synapsor Runner HTTP MCP listening on ${url}
|
|
12325
12907
|
`);
|
|
12326
|
-
log.write(
|
|
12908
|
+
log.write(`Channel: ${security.channel}; deployment: ${security.deployment}
|
|
12909
|
+
`);
|
|
12910
|
+
if (options.tls) log.write(options.tls.requestClientCert ? "TLS: enabled, client certificates required in addition to Bearer auth\n" : "TLS: enabled\n");
|
|
12911
|
+
log.write(devNoAuth ? "Auth: disabled for loopback development only\n" : `Auth: opaque Bearer endpoint token from ${security.activeTokenEnv}${security.previousTokenEnv ? `; previous rotation token from ${security.previousTokenEnv}` : ""}
|
|
12327
12912
|
`);
|
|
12913
|
+
if (security.weakStaticToken) log.write("Auth warning: loopback endpoint token is shorter or more predictable than the production requirement; generate at least 32 random bytes.\n");
|
|
12914
|
+
if (security.channel === "insecure_http_break_glass") log.write("SECURITY WARNING: remote Bearer traffic is using explicit insecure cleartext break glass. Credentials and data can be intercepted.\n");
|
|
12328
12915
|
log.write(`Config: ${options.configPath ?? "synapsor.runner.json"}
|
|
12329
12916
|
`);
|
|
12330
12917
|
log.write(`Store: ${options.storePath ?? config.storage?.sqlite_path ?? "./.synapsor/local.db"}
|
|
@@ -12340,29 +12927,23 @@ async function startHttpMcpServer(options = {}) {
|
|
|
12340
12927
|
async function startStreamableHttpMcpServer(options = {}) {
|
|
12341
12928
|
const host = options.host ?? "127.0.0.1";
|
|
12342
12929
|
const port = options.port ?? 8766;
|
|
12343
|
-
const authTokenEnv = options.authTokenEnv ?? "SYNAPSOR_RUNNER_HTTP_TOKEN";
|
|
12344
12930
|
const env = options.env ?? process.env;
|
|
12345
12931
|
const devNoAuth = options.devNoAuth === true;
|
|
12346
12932
|
const config = resolveRuntimeConfig(options.config ?? loadRuntimeConfigFromFile(options.configPath));
|
|
12347
12933
|
assertValidRunnerCapabilityConfig(config);
|
|
12348
12934
|
const usesSessionAuth = configUsesHttpClaims(config);
|
|
12935
|
+
const security = resolveHttpSecurity(config, options, host, env, usesSessionAuth);
|
|
12349
12936
|
const metricsAccess = resolveMetricsEndpointAccess(config, env, host);
|
|
12350
|
-
if (devNoAuth && !isLoopbackHost(host)) {
|
|
12351
|
-
throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
|
|
12352
|
-
}
|
|
12353
12937
|
if (devNoAuth && usesSessionAuth) {
|
|
12354
12938
|
throw new McpRuntimeError("HTTP_CLAIMS_AUTH_REQUIRED", "http_claims trusted context cannot run with --dev-no-auth.");
|
|
12355
12939
|
}
|
|
12356
|
-
const authToken = devNoAuth || usesSessionAuth ? void 0 : envValue(env, authTokenEnv);
|
|
12357
|
-
if (!devNoAuth && !usesSessionAuth && !authToken) {
|
|
12358
|
-
throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${authTokenEnv} is not set. Streamable HTTP MCP requires bearer auth by default.`);
|
|
12359
|
-
}
|
|
12360
12940
|
assertRuntimeStoreStartupReady(config, env);
|
|
12361
12941
|
const sessionVerifier = usesSessionAuth ? sessionAuthVerifier(config, env, options.configPath ? path2.dirname(path2.resolve(options.configPath)) : process.cwd()) : void 0;
|
|
12362
12942
|
const readinessCheck = options.readinessCheck ?? (() => checkRunnerReadiness(config, env));
|
|
12363
12943
|
if (options.tls?.requestClientCert && !options.tls.ca) {
|
|
12364
12944
|
throw new McpRuntimeError("MTLS_CA_REQUIRED", "Streamable HTTP mTLS requires a CA bundle when client certificates are required.");
|
|
12365
12945
|
}
|
|
12946
|
+
validateTlsMaterial(options.tls);
|
|
12366
12947
|
const cloudTools = config.mode === "cloud" ? await fetchCloudToolMetadata(config, env) : void 0;
|
|
12367
12948
|
if (options.readRow && Object.values(config.sources ?? {}).some((source) => source.database_scope?.mode === "postgres_rls")) {
|
|
12368
12949
|
throw new McpRuntimeError("POSTGRES_RLS_CUSTOM_READER_UNVERIFIED", "Hardened postgres_rls mode requires Runner's verified PostgreSQL reader; a custom readRow cannot be attested by the stock server.");
|
|
@@ -12375,6 +12956,7 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
12375
12956
|
const sharedResources = createMcpRuntimeSharedResources(config, env, options.readRow, Date.now, options.credentialResolver);
|
|
12376
12957
|
const sessions = /* @__PURE__ */ new Map();
|
|
12377
12958
|
const openSessions = /* @__PURE__ */ new Set();
|
|
12959
|
+
const initializingSessions = { count: 0 };
|
|
12378
12960
|
const requestHandler = (request, response) => {
|
|
12379
12961
|
void handleStreamableHttpMcpRequest({
|
|
12380
12962
|
request,
|
|
@@ -12387,12 +12969,12 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
12387
12969
|
env,
|
|
12388
12970
|
toolNameStyle: options.toolNameStyle,
|
|
12389
12971
|
resultFormat: options.resultFormat,
|
|
12390
|
-
authToken,
|
|
12391
12972
|
sessionVerifier,
|
|
12392
12973
|
devNoAuth,
|
|
12393
|
-
|
|
12974
|
+
security,
|
|
12394
12975
|
sessions,
|
|
12395
12976
|
openSessions,
|
|
12977
|
+
initializingSessions,
|
|
12396
12978
|
readinessCheck,
|
|
12397
12979
|
metricsAccess,
|
|
12398
12980
|
metricsProvider: () => renderRuntimeMetrics(sharedStore, sharedResources.poolMetrics(), sharedResources.rateLimitMetrics(), readinessCheck)
|
|
@@ -12403,8 +12985,14 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
12403
12985
|
key: options.tls.key,
|
|
12404
12986
|
ca: options.tls.ca,
|
|
12405
12987
|
requestCert: options.tls.requestClientCert === true,
|
|
12406
|
-
rejectUnauthorized: options.tls.requestClientCert === true
|
|
12407
|
-
|
|
12988
|
+
rejectUnauthorized: options.tls.requestClientCert === true,
|
|
12989
|
+
maxHeaderSize: security.limits.maxHeaderBytes
|
|
12990
|
+
}, requestHandler) : createServer({ maxHeaderSize: security.limits.maxHeaderBytes }, requestHandler);
|
|
12991
|
+
applyHttpServerLimits(server, security.limits);
|
|
12992
|
+
const sessionReaper = setInterval(() => {
|
|
12993
|
+
void pruneExpiredStreamableSessions(sessions, openSessions, security.limits.sessionIdleTimeoutMs, Date.now());
|
|
12994
|
+
}, Math.min(3e4, Math.max(1e3, Math.floor(security.limits.sessionIdleTimeoutMs / 2))));
|
|
12995
|
+
sessionReaper.unref?.();
|
|
12408
12996
|
try {
|
|
12409
12997
|
await new Promise((resolve2, reject) => {
|
|
12410
12998
|
server.once("error", reject);
|
|
@@ -12414,6 +13002,7 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
12414
13002
|
});
|
|
12415
13003
|
});
|
|
12416
13004
|
} catch (error) {
|
|
13005
|
+
clearInterval(sessionReaper);
|
|
12417
13006
|
await cloudSynchronizer?.stop();
|
|
12418
13007
|
await closeStreamableSessions(openSessions);
|
|
12419
13008
|
await sharedResources.close();
|
|
@@ -12429,10 +13018,16 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
12429
13018
|
const log = options.log ?? process.stderr;
|
|
12430
13019
|
log.write(`Synapsor Runner Streamable HTTP MCP listening on ${url}
|
|
12431
13020
|
`);
|
|
12432
|
-
|
|
12433
|
-
|
|
12434
|
-
|
|
13021
|
+
log.write(`Channel: ${security.channel}; deployment: ${security.deployment}
|
|
13022
|
+
`);
|
|
13023
|
+
if (options.tls) log.write(options.tls.requestClientCert ? "TLS: enabled, client certificates required in addition to Bearer auth\n" : "TLS: enabled\n");
|
|
13024
|
+
log.write(devNoAuth ? "Auth: disabled for localhost development only\n" : usesSessionAuth ? `Auth: signed per-session JWT (${config.session_auth?.provider}); issuer and resource checked on every request
|
|
13025
|
+
` : `Auth: opaque Bearer endpoint token from ${security.activeTokenEnv}${security.previousTokenEnv ? `; previous rotation token from ${security.previousTokenEnv}` : ""}
|
|
12435
13026
|
`);
|
|
13027
|
+
if (security.oauth) log.write(`OAuth resource metadata: ${security.oauth.metadataUrl}
|
|
13028
|
+
`);
|
|
13029
|
+
if (security.weakStaticToken) log.write("Auth warning: loopback endpoint token is shorter or more predictable than the production requirement; generate at least 32 random bytes.\n");
|
|
13030
|
+
if (security.channel === "insecure_http_break_glass") log.write("SECURITY WARNING: remote Bearer traffic is using explicit insecure cleartext break glass. Credentials and data can be intercepted.\n");
|
|
12436
13031
|
for (const assurance of describeIsolationAssurance(config)) {
|
|
12437
13032
|
log.write(`Isolation ${assurance.source}: ${assurance.mode}; trusted context: ${assurance.trusted_context.request_binding}
|
|
12438
13033
|
`);
|
|
@@ -12448,19 +13043,23 @@ async function startStreamableHttpMcpServer(options = {}) {
|
|
|
12448
13043
|
host: actualHost,
|
|
12449
13044
|
port: actualPort,
|
|
12450
13045
|
url,
|
|
12451
|
-
close: () =>
|
|
13046
|
+
close: () => {
|
|
13047
|
+
clearInterval(sessionReaper);
|
|
13048
|
+
return closeStreamableHttpServer(server, openSessions, sharedResources, sharedStore, cloudSynchronizer);
|
|
13049
|
+
}
|
|
12452
13050
|
};
|
|
12453
13051
|
}
|
|
12454
13052
|
async function handleStreamableHttpMcpRequest(input) {
|
|
12455
|
-
const { request, response, config, storePath, sharedStore, sharedResources, cloudTools, env, toolNameStyle, resultFormat,
|
|
13053
|
+
const { request, response, config, storePath, sharedStore, sharedResources, cloudTools, env, toolNameStyle, resultFormat, sessionVerifier, devNoAuth, security, sessions, openSessions, initializingSessions, readinessCheck, metricsAccess, metricsProvider } = input;
|
|
12456
13054
|
try {
|
|
12457
|
-
|
|
12458
|
-
if (request.method === "OPTIONS" &&
|
|
13055
|
+
if (!validateHttpRequestSecurity(request, response, security)) return;
|
|
13056
|
+
if (request.method === "OPTIONS" && request.headers.origin) {
|
|
12459
13057
|
response.statusCode = 204;
|
|
12460
13058
|
response.end();
|
|
12461
13059
|
return;
|
|
12462
13060
|
}
|
|
12463
13061
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
13062
|
+
if (maybeServeOauthMetadata(request, response, security, url.pathname)) return;
|
|
12464
13063
|
if (request.method === "GET" && url.pathname === "/healthz") {
|
|
12465
13064
|
writeJson(response, 200, {
|
|
12466
13065
|
ok: true,
|
|
@@ -12482,11 +13081,13 @@ async function handleStreamableHttpMcpRequest(input) {
|
|
|
12482
13081
|
writeJson(response, 404, { ok: false, error: "not_found" });
|
|
12483
13082
|
return;
|
|
12484
13083
|
}
|
|
12485
|
-
const
|
|
12486
|
-
if (!
|
|
12487
|
-
|
|
13084
|
+
const authResult = await authenticateStreamableRequest(config, request.headers.authorization, sessionVerifier, security, devNoAuth);
|
|
13085
|
+
if (!authResult.ok) {
|
|
13086
|
+
writeAuthenticationFailure(response, security, authResult.status, authResult.error);
|
|
12488
13087
|
return;
|
|
12489
13088
|
}
|
|
13089
|
+
const authentication = authResult.authentication;
|
|
13090
|
+
await pruneExpiredStreamableSessions(sessions, openSessions, security.limits.sessionIdleTimeoutMs, Date.now());
|
|
12490
13091
|
const sessionId = headerValue(request.headers["mcp-session-id"]);
|
|
12491
13092
|
if (sessionId) {
|
|
12492
13093
|
const existing = sessions.get(sessionId);
|
|
@@ -12495,9 +13096,10 @@ async function handleStreamableHttpMcpRequest(input) {
|
|
|
12495
13096
|
return;
|
|
12496
13097
|
}
|
|
12497
13098
|
if (existing.authFingerprint !== authentication.fingerprint) {
|
|
12498
|
-
|
|
13099
|
+
writeAuthenticationFailure(response, security, 401, "unauthorized");
|
|
12499
13100
|
return;
|
|
12500
13101
|
}
|
|
13102
|
+
existing.lastSeenAt = Date.now();
|
|
12501
13103
|
await existing.transport.handleRequest(request, response);
|
|
12502
13104
|
return;
|
|
12503
13105
|
}
|
|
@@ -12505,54 +13107,73 @@ async function handleStreamableHttpMcpRequest(input) {
|
|
|
12505
13107
|
writeJson(response, 400, jsonRpcError(null, -32e3, "MCP initialize request is required before using this Streamable HTTP session."));
|
|
12506
13108
|
return;
|
|
12507
13109
|
}
|
|
12508
|
-
|
|
12509
|
-
|
|
12510
|
-
writeJson(response,
|
|
13110
|
+
if (openSessions.size + initializingSessions.count >= security.limits.maxSessions) {
|
|
13111
|
+
response.setHeader("retry-after", "1");
|
|
13112
|
+
writeJson(response, 503, { ok: false, error: "session_capacity_exhausted", retryable: true, retry_after_ms: 1e3 });
|
|
12511
13113
|
return;
|
|
12512
13114
|
}
|
|
12513
|
-
|
|
12514
|
-
|
|
12515
|
-
|
|
12516
|
-
|
|
12517
|
-
|
|
12518
|
-
|
|
12519
|
-
|
|
12520
|
-
|
|
12521
|
-
|
|
12522
|
-
|
|
12523
|
-
|
|
12524
|
-
|
|
12525
|
-
|
|
13115
|
+
initializingSessions.count += 1;
|
|
13116
|
+
let initializingSession;
|
|
13117
|
+
try {
|
|
13118
|
+
const parsedBody = JSON.parse(await readRequestBody(request, security.limits.maxRequestBytes));
|
|
13119
|
+
if (!containsInitializeRequest(parsedBody)) {
|
|
13120
|
+
writeJson(response, 400, jsonRpcError(requestIdFromPayload(parsedBody), -32e3, "First Streamable HTTP MCP request must be initialize."));
|
|
13121
|
+
return;
|
|
13122
|
+
}
|
|
13123
|
+
const transport = new StreamableHTTPServerTransport({
|
|
13124
|
+
sessionIdGenerator: () => crypto3.randomUUID(),
|
|
13125
|
+
onsessioninitialized: (newSessionId) => {
|
|
13126
|
+
if (initializingSession) {
|
|
13127
|
+
initializingSession.sessionId = newSessionId;
|
|
13128
|
+
sessions.set(newSessionId, initializingSession);
|
|
13129
|
+
}
|
|
13130
|
+
},
|
|
13131
|
+
onsessionclosed: (closedSessionId) => {
|
|
13132
|
+
const closed = sessions.get(closedSessionId);
|
|
13133
|
+
if (closed) {
|
|
13134
|
+
disposeStreamableSession(closed, sessions, openSessions);
|
|
13135
|
+
}
|
|
12526
13136
|
}
|
|
13137
|
+
});
|
|
13138
|
+
const runtime = createMcpRuntime(config, {
|
|
13139
|
+
env,
|
|
13140
|
+
storePath,
|
|
13141
|
+
store: sharedStore,
|
|
13142
|
+
sharedResources,
|
|
13143
|
+
resultFormat,
|
|
13144
|
+
cloudTools,
|
|
13145
|
+
trustedContext: authentication.context
|
|
13146
|
+
});
|
|
13147
|
+
initializingSession = { transport, runtime, authFingerprint: authentication.fingerprint, lastSeenAt: Date.now() };
|
|
13148
|
+
openSessions.add(initializingSession);
|
|
13149
|
+
transport.onclose = () => {
|
|
13150
|
+
if (initializingSession) disposeStreamableSession(initializingSession, sessions, openSessions);
|
|
13151
|
+
};
|
|
13152
|
+
await createSynapsorMcpServer(runtime, { toolNameStyle }).connect(transport);
|
|
13153
|
+
await transport.handleRequest(request, response, parsedBody);
|
|
13154
|
+
} catch (error) {
|
|
13155
|
+
if (initializingSession) {
|
|
13156
|
+
disposeStreamableSession(initializingSession, sessions, openSessions);
|
|
13157
|
+
await initializingSession.transport.close().catch(() => void 0);
|
|
12527
13158
|
}
|
|
12528
|
-
|
|
12529
|
-
|
|
12530
|
-
|
|
12531
|
-
|
|
12532
|
-
store: sharedStore,
|
|
12533
|
-
sharedResources,
|
|
12534
|
-
resultFormat,
|
|
12535
|
-
cloudTools,
|
|
12536
|
-
trustedContext: authentication.context
|
|
12537
|
-
});
|
|
12538
|
-
session = { transport, runtime, authFingerprint: authentication.fingerprint };
|
|
12539
|
-
openSessions.add(session);
|
|
12540
|
-
transport.onclose = () => {
|
|
12541
|
-
if (session) disposeStreamableSession(session, sessions, openSessions);
|
|
12542
|
-
};
|
|
12543
|
-
await createSynapsorMcpServer(runtime, { toolNameStyle }).connect(transport);
|
|
12544
|
-
await transport.handleRequest(request, response, parsedBody);
|
|
13159
|
+
throw error;
|
|
13160
|
+
} finally {
|
|
13161
|
+
initializingSessions.count -= 1;
|
|
13162
|
+
}
|
|
12545
13163
|
} catch (error) {
|
|
12546
|
-
const message2 = sanitizeHttpError(error,
|
|
12547
|
-
if (!response.headersSent
|
|
13164
|
+
const message2 = sanitizeHttpError(error, security.activeToken, security.previousToken);
|
|
13165
|
+
if (!response.headersSent && error instanceof McpRuntimeError && error.code === "HTTP_BODY_TOO_LARGE") {
|
|
13166
|
+
writeJson(response, 413, { ok: false, error: "request_too_large" });
|
|
13167
|
+
} else if (!response.headersSent) writeJson(response, 200, jsonRpcError(null, -32e3, message2));
|
|
12548
13168
|
else response.end();
|
|
12549
13169
|
}
|
|
12550
13170
|
}
|
|
12551
13171
|
async function handleHttpMcpRequest(input) {
|
|
12552
|
-
const { request, response, runtime,
|
|
13172
|
+
const { request, response, runtime, devNoAuth, security, readinessCheck, metricsAccess, metricsProvider } = input;
|
|
12553
13173
|
try {
|
|
12554
|
-
setCommonHttpHeaders(response
|
|
12555
|
-
if (request
|
|
13174
|
+
setCommonHttpHeaders(response);
|
|
13175
|
+
if (!validateHttpRequestSecurity(request, response, security)) return;
|
|
13176
|
+
if (request.method === "OPTIONS" && request.headers.origin) {
|
|
12556
13177
|
response.statusCode = 204;
|
|
12557
13178
|
response.end();
|
|
12558
13179
|
return;
|
|
@@ -12583,13 +13204,13 @@ async function handleHttpMcpRequest(input) {
|
|
|
12583
13204
|
writeJson(response, 405, { ok: false, error: "method_not_allowed" });
|
|
12584
13205
|
return;
|
|
12585
13206
|
}
|
|
12586
|
-
if (!devNoAuth && !
|
|
12587
|
-
|
|
13207
|
+
if (!devNoAuth && !validBearerTokens(request.headers.authorization, [security.activeToken, security.previousToken])) {
|
|
13208
|
+
writeAuthenticationFailure(response, security, 401, "unauthorized");
|
|
12588
13209
|
return;
|
|
12589
13210
|
}
|
|
12590
|
-
const body = await readRequestBody(request);
|
|
13211
|
+
const body = await readRequestBody(request, security.limits.maxRequestBytes);
|
|
12591
13212
|
const payload = JSON.parse(body);
|
|
12592
|
-
if (!
|
|
13213
|
+
if (!isRecord5(payload)) {
|
|
12593
13214
|
writeJson(response, 400, jsonRpcError(null, -32600, "JSON-RPC request must be an object."));
|
|
12594
13215
|
return;
|
|
12595
13216
|
}
|
|
@@ -12599,15 +13220,18 @@ async function handleHttpMcpRequest(input) {
|
|
|
12599
13220
|
writeJson(response, 400, jsonRpcError(id, -32600, "JSON-RPC method is required."));
|
|
12600
13221
|
return;
|
|
12601
13222
|
}
|
|
12602
|
-
const result = await handleHttpJsonRpcMethod(runtime, method,
|
|
13223
|
+
const result = await handleHttpJsonRpcMethod(runtime, method, isRecord5(payload.params) ? payload.params : {});
|
|
12603
13224
|
writeJson(response, 200, {
|
|
12604
13225
|
jsonrpc: "2.0",
|
|
12605
13226
|
id,
|
|
12606
|
-
result: sanitizeHttpPayload(result,
|
|
13227
|
+
result: sanitizeHttpPayload(result, security.activeToken, security.previousToken)
|
|
12607
13228
|
});
|
|
12608
13229
|
} catch (error) {
|
|
12609
|
-
const message2 = sanitizeHttpError(error,
|
|
12610
|
-
|
|
13230
|
+
const message2 = sanitizeHttpError(error, security.activeToken, security.previousToken);
|
|
13231
|
+
if (!response.headersSent && error instanceof McpRuntimeError && error.code === "HTTP_BODY_TOO_LARGE") {
|
|
13232
|
+
writeJson(response, 413, { ok: false, error: "request_too_large" });
|
|
13233
|
+
} else if (!response.headersSent) writeJson(response, 200, jsonRpcError(null, -32e3, message2));
|
|
13234
|
+
else response.end();
|
|
12611
13235
|
}
|
|
12612
13236
|
}
|
|
12613
13237
|
async function handleHttpJsonRpcMethod(runtime, method, params) {
|
|
@@ -12619,7 +13243,7 @@ async function handleHttpJsonRpcMethod(runtime, method, params) {
|
|
|
12619
13243
|
if (method === "tools/call") {
|
|
12620
13244
|
const name = typeof params.name === "string" ? params.name : void 0;
|
|
12621
13245
|
if (!name) throw new McpRuntimeError("HTTP_TOOL_NAME_REQUIRED", "tools/call requires params.name.");
|
|
12622
|
-
const args =
|
|
13246
|
+
const args = isRecord5(params.arguments) ? params.arguments : isRecord5(params.args) ? params.args : {};
|
|
12623
13247
|
return await toolCallResult(runtime, name, args);
|
|
12624
13248
|
}
|
|
12625
13249
|
if (method === "resources/read") {
|
|
@@ -12649,11 +13273,21 @@ function httpToolMetadata(tool) {
|
|
|
12649
13273
|
};
|
|
12650
13274
|
}
|
|
12651
13275
|
function validBearerToken(header, expected) {
|
|
12652
|
-
|
|
12653
|
-
|
|
12654
|
-
|
|
12655
|
-
const
|
|
12656
|
-
|
|
13276
|
+
return validBearerTokens(header, [expected]);
|
|
13277
|
+
}
|
|
13278
|
+
function validBearerTokens(header, expected) {
|
|
13279
|
+
const actual = bearerToken(header);
|
|
13280
|
+
if (!actual) return false;
|
|
13281
|
+
let matched = 0;
|
|
13282
|
+
for (const candidate of expected.filter((value) => Boolean(value))) {
|
|
13283
|
+
matched |= Number(constantTimeTokenEquals(actual, candidate));
|
|
13284
|
+
}
|
|
13285
|
+
return matched === 1;
|
|
13286
|
+
}
|
|
13287
|
+
function constantTimeTokenEquals(actual, expected) {
|
|
13288
|
+
const actualDigest = crypto3.createHash("sha256").update(actual, "utf8").digest();
|
|
13289
|
+
const expectedDigest = crypto3.createHash("sha256").update(expected, "utf8").digest();
|
|
13290
|
+
return crypto3.timingSafeEqual(actualDigest, expectedDigest);
|
|
12657
13291
|
}
|
|
12658
13292
|
function resolveMetricsEndpointAccess(config, env, host) {
|
|
12659
13293
|
if (config.metrics?.enabled !== true) return { enabled: false };
|
|
@@ -12760,12 +13394,12 @@ async function checkRunnerReadiness(config, env = process.env, timeoutMs = 3e3)
|
|
|
12760
13394
|
if (!executorName || checkedExecutors.has(executorName)) continue;
|
|
12761
13395
|
checkedExecutors.add(executorName);
|
|
12762
13396
|
components.push(await readinessComponent(`executor:${executorName}`, "EXECUTOR_READY", "EXECUTOR_UNAVAILABLE", timeoutMs, async () => {
|
|
12763
|
-
const executor =
|
|
13397
|
+
const executor = isRecord5(config.executors?.[executorName]) ? config.executors?.[executorName] : void 0;
|
|
12764
13398
|
if (!executor) throw new Error("executor missing");
|
|
12765
13399
|
if (executor.type === "http_handler") {
|
|
12766
13400
|
if (typeof executor.url_env !== "string" || !envValue(env, executor.url_env)) throw new Error("handler URL unavailable");
|
|
12767
13401
|
const handlerUrl = envValue(env, executor.url_env);
|
|
12768
|
-
const auth =
|
|
13402
|
+
const auth = isRecord5(executor.auth) ? executor.auth : void 0;
|
|
12769
13403
|
if (auth?.type === "bearer_env" && (typeof auth.token_env !== "string" || !envValue(env, auth.token_env))) throw new Error("handler token unavailable");
|
|
12770
13404
|
const response = await fetch(handlerUrl, {
|
|
12771
13405
|
method: "HEAD",
|
|
@@ -12858,20 +13492,25 @@ function sessionAuthVerifier(config, env, baseDir) {
|
|
|
12858
13492
|
throw new McpRuntimeError("SESSION_AUTH_INVALID", message2);
|
|
12859
13493
|
}
|
|
12860
13494
|
}
|
|
12861
|
-
async function authenticateStreamableRequest(config, authorization, sessionVerifier,
|
|
12862
|
-
if (devNoAuth) return { fingerprint: "dev-no-auth" };
|
|
13495
|
+
async function authenticateStreamableRequest(config, authorization, sessionVerifier, security, devNoAuth) {
|
|
13496
|
+
if (devNoAuth) return { ok: true, authentication: { fingerprint: "dev-no-auth" } };
|
|
12863
13497
|
const token = bearerToken(authorization);
|
|
12864
|
-
if (!token) return
|
|
13498
|
+
if (!token) return { ok: false, status: 401, error: "unauthorized" };
|
|
12865
13499
|
if (!configUsesHttpClaims(config)) {
|
|
12866
|
-
if (!
|
|
12867
|
-
|
|
13500
|
+
if (!validBearerTokens(authorization, [security.activeToken, security.previousToken])) {
|
|
13501
|
+
return { ok: false, status: 401, error: "unauthorized" };
|
|
13502
|
+
}
|
|
13503
|
+
return { ok: true, authentication: { fingerprint: tokenFingerprint(token) } };
|
|
12868
13504
|
}
|
|
12869
13505
|
try {
|
|
12870
|
-
if (!sessionVerifier) return
|
|
13506
|
+
if (!sessionVerifier) return { ok: false, status: 401, error: "unauthorized" };
|
|
12871
13507
|
const context = await verifySessionJwt(config, token, sessionVerifier);
|
|
12872
|
-
return { fingerprint: tokenFingerprint(token), context };
|
|
12873
|
-
} catch {
|
|
12874
|
-
|
|
13508
|
+
return { ok: true, authentication: { fingerprint: tokenFingerprint(token), context } };
|
|
13509
|
+
} catch (error) {
|
|
13510
|
+
if (error instanceof McpRuntimeError && error.code === "HTTP_INSUFFICIENT_SCOPE") {
|
|
13511
|
+
return { ok: false, status: 403, error: "insufficient_scope" };
|
|
13512
|
+
}
|
|
13513
|
+
return { ok: false, status: 401, error: "unauthorized" };
|
|
12875
13514
|
}
|
|
12876
13515
|
}
|
|
12877
13516
|
async function verifySessionJwt(config, token, verifier) {
|
|
@@ -12881,12 +13520,34 @@ async function verifySessionJwt(config, token, verifier) {
|
|
|
12881
13520
|
const tenant = safeSessionClaim(claims[auth.tenant_claim ?? "tenant_id"]);
|
|
12882
13521
|
const principal = safeSessionClaim(claims[auth.principal_claim ?? "sub"]);
|
|
12883
13522
|
if (!tenant || !principal) throw new Error("JWT trusted context claims are missing or unsafe");
|
|
13523
|
+
const requiredScopes = config.http_security?.oauth_resource?.required_scopes ?? [];
|
|
13524
|
+
if (requiredScopes.length > 0) {
|
|
13525
|
+
const granted = safeJwtScopes(claims.scope, claims.scp);
|
|
13526
|
+
if (!requiredScopes.every((scope) => granted.has(scope))) {
|
|
13527
|
+
throw new McpRuntimeError("HTTP_INSUFFICIENT_SCOPE", "JWT does not grant the required MCP resource scope.");
|
|
13528
|
+
}
|
|
13529
|
+
}
|
|
12884
13530
|
return { tenant_id: tenant, principal, provenance: "http_claims" };
|
|
12885
13531
|
}
|
|
12886
13532
|
function bearerToken(header) {
|
|
12887
|
-
|
|
12888
|
-
const token =
|
|
12889
|
-
return token
|
|
13533
|
+
const match = /^Bearer[ \t]+([^\s,]+)$/i.exec(header ?? "");
|
|
13534
|
+
const token = match?.[1];
|
|
13535
|
+
return token && token.length <= 16384 ? token : void 0;
|
|
13536
|
+
}
|
|
13537
|
+
function safeJwtScopes(scope, scp) {
|
|
13538
|
+
const values = [];
|
|
13539
|
+
if (typeof scope === "string" && scope.length <= 8192 && !/[\u0000-\u001f\u007f]/.test(scope)) {
|
|
13540
|
+
values.push(...scope.split(/\s+/).filter(Boolean));
|
|
13541
|
+
} else if (scope !== void 0) {
|
|
13542
|
+
throw new Error("JWT scope claim is unsafe");
|
|
13543
|
+
}
|
|
13544
|
+
if (Array.isArray(scp) && scp.length <= 64 && scp.every((value) => typeof value === "string" && value.length <= 128 && !/[\s\u0000-\u001f\u007f]/.test(value))) {
|
|
13545
|
+
values.push(...scp);
|
|
13546
|
+
} else if (scp !== void 0) {
|
|
13547
|
+
throw new Error("JWT scp claim is unsafe");
|
|
13548
|
+
}
|
|
13549
|
+
if (values.length > 128 || values.some((value) => value.length > 128)) throw new Error("JWT scope claim is unsafe");
|
|
13550
|
+
return new Set(values);
|
|
12890
13551
|
}
|
|
12891
13552
|
function tokenFingerprint(token) {
|
|
12892
13553
|
return `sha256:${crypto3.createHash("sha256").update(token).digest("hex")}`;
|
|
@@ -12906,10 +13567,10 @@ function containsInitializeRequest(payload) {
|
|
|
12906
13567
|
}
|
|
12907
13568
|
function requestIdFromPayload(payload) {
|
|
12908
13569
|
if (Array.isArray(payload)) {
|
|
12909
|
-
const request = payload.find((message2) =>
|
|
12910
|
-
return
|
|
13570
|
+
const request = payload.find((message2) => isRecord5(message2) && "id" in message2);
|
|
13571
|
+
return isRecord5(request) ? request.id ?? null : null;
|
|
12911
13572
|
}
|
|
12912
|
-
return
|
|
13573
|
+
return isRecord5(payload) ? payload.id ?? null : null;
|
|
12913
13574
|
}
|
|
12914
13575
|
function openaiToolNameAlias(canonicalName) {
|
|
12915
13576
|
const sanitized = canonicalName.replace(/[^A-Za-z0-9_-]+/g, "__").replace(/_{3,}/g, "__").replace(/^_+|_+$/g, "");
|
|
@@ -12964,14 +13625,14 @@ function writeJson(response, statusCode, payload) {
|
|
|
12964
13625
|
response.end(`${JSON.stringify(payload, null, 2)}
|
|
12965
13626
|
`);
|
|
12966
13627
|
}
|
|
12967
|
-
async function readRequestBody(request) {
|
|
13628
|
+
async function readRequestBody(request, maxBytes = 1048576) {
|
|
12968
13629
|
const chunks = [];
|
|
12969
13630
|
let bytes = 0;
|
|
12970
13631
|
for await (const chunk of request) {
|
|
12971
13632
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
12972
13633
|
bytes += buffer.length;
|
|
12973
|
-
if (bytes >
|
|
12974
|
-
throw new McpRuntimeError("HTTP_BODY_TOO_LARGE",
|
|
13634
|
+
if (bytes > maxBytes) {
|
|
13635
|
+
throw new McpRuntimeError("HTTP_BODY_TOO_LARGE", `HTTP MCP request body exceeds the configured ${maxBytes}-byte limit.`);
|
|
12975
13636
|
}
|
|
12976
13637
|
chunks.push(buffer);
|
|
12977
13638
|
}
|
|
@@ -12984,21 +13645,21 @@ function jsonRpcError(id, code, message2) {
|
|
|
12984
13645
|
error: { code, message: message2 }
|
|
12985
13646
|
};
|
|
12986
13647
|
}
|
|
12987
|
-
function sanitizeHttpError(error,
|
|
13648
|
+
function sanitizeHttpError(error, ...authTokens) {
|
|
12988
13649
|
const raw = error instanceof Error ? error.message : String(error);
|
|
12989
|
-
return sanitizeHttpString(raw,
|
|
13650
|
+
return sanitizeHttpString(raw, ...authTokens);
|
|
12990
13651
|
}
|
|
12991
|
-
function sanitizeHttpPayload(value,
|
|
12992
|
-
if (typeof value === "string") return sanitizeHttpString(value,
|
|
12993
|
-
if (Array.isArray(value)) return value.map((item) => sanitizeHttpPayload(item,
|
|
12994
|
-
if (
|
|
12995
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeHttpPayload(item,
|
|
13652
|
+
function sanitizeHttpPayload(value, ...authTokens) {
|
|
13653
|
+
if (typeof value === "string") return sanitizeHttpString(value, ...authTokens);
|
|
13654
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeHttpPayload(item, ...authTokens));
|
|
13655
|
+
if (isRecord5(value)) {
|
|
13656
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeHttpPayload(item, ...authTokens)]));
|
|
12996
13657
|
}
|
|
12997
13658
|
return value;
|
|
12998
13659
|
}
|
|
12999
|
-
function sanitizeHttpString(value,
|
|
13660
|
+
function sanitizeHttpString(value, ...authTokens) {
|
|
13000
13661
|
let redacted = value.replace(/(?:postgres(?:ql)?|mysql):\/\/[^\s"']+/gi, "[redacted-database-url]");
|
|
13001
|
-
if (authToken) redacted = redacted.split(authToken).join("[redacted-token]");
|
|
13662
|
+
for (const authToken of authTokens) if (authToken) redacted = redacted.split(authToken).join("[redacted-token]");
|
|
13002
13663
|
return redacted;
|
|
13003
13664
|
}
|
|
13004
13665
|
function isLoopbackHost(host) {
|
|
@@ -13032,6 +13693,13 @@ async function closeStreamableSessions(sessions) {
|
|
|
13032
13693
|
disposeStreamableSession(session);
|
|
13033
13694
|
}
|
|
13034
13695
|
}
|
|
13696
|
+
async function pruneExpiredStreamableSessions(sessions, openSessions, idleTimeoutMs, now) {
|
|
13697
|
+
const expired = [...openSessions].filter((session) => now - session.lastSeenAt >= idleTimeoutMs);
|
|
13698
|
+
for (const session of expired) {
|
|
13699
|
+
disposeStreamableSession(session, sessions, openSessions);
|
|
13700
|
+
await session.transport.close().catch(() => void 0);
|
|
13701
|
+
}
|
|
13702
|
+
}
|
|
13035
13703
|
function disposeStreamableSession(session, sessionMap, openSessions) {
|
|
13036
13704
|
if (session.closed) return;
|
|
13037
13705
|
session.closed = true;
|
|
@@ -13058,7 +13726,7 @@ async function toolCallResult(runtime, toolName, args) {
|
|
|
13058
13726
|
}
|
|
13059
13727
|
async function withProposalReviewPresentation(runtime, result) {
|
|
13060
13728
|
const directId = typeof result.proposal_id === "string" ? result.proposal_id : void 0;
|
|
13061
|
-
const nested =
|
|
13729
|
+
const nested = isRecord5(result.proposal) ? result.proposal : void 0;
|
|
13062
13730
|
const proposalId = directId ?? (typeof nested?.id === "string" ? nested.id : void 0);
|
|
13063
13731
|
if (!proposalId || proposalId === "wrp_unknown") return result;
|
|
13064
13732
|
try {
|
|
@@ -13139,11 +13807,11 @@ function toolDescriptionWithCanonical(description, canonicalName, exposedName) {
|
|
|
13139
13807
|
${description}`;
|
|
13140
13808
|
}
|
|
13141
13809
|
function zodInputShapeFromJsonSchema(schema) {
|
|
13142
|
-
const properties =
|
|
13810
|
+
const properties = isRecord5(schema.properties) ? schema.properties : {};
|
|
13143
13811
|
const required = Array.isArray(schema.required) ? new Set(schema.required.map(String)) : /* @__PURE__ */ new Set();
|
|
13144
13812
|
const shape = {};
|
|
13145
13813
|
for (const [name, rawProperty] of Object.entries(properties)) {
|
|
13146
|
-
const property =
|
|
13814
|
+
const property = isRecord5(rawProperty) ? rawProperty : {};
|
|
13147
13815
|
let valueSchema;
|
|
13148
13816
|
if (Array.isArray(property.enum)) {
|
|
13149
13817
|
const allowed = property.enum.map((item) => scalar2(item));
|
|
@@ -13546,13 +14214,13 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
|
13546
14214
|
const kind = capability?.kind ?? (typeof legacy.proposal_id === "string" ? "proposal" : "read");
|
|
13547
14215
|
const evidenceBundleId = typeof legacy.evidence_bundle_id === "string" ? legacy.evidence_bundle_id : void 0;
|
|
13548
14216
|
const sourceChanged = Boolean(legacy.source_database_changed ?? legacy.source_database_mutated ?? false);
|
|
13549
|
-
const context =
|
|
13550
|
-
const target =
|
|
14217
|
+
const context = isRecord5(legacy.trusted_context) ? legacy.trusted_context : void 0;
|
|
14218
|
+
const target = isRecord5(legacy.target) ? legacy.target : void 0;
|
|
13551
14219
|
if (kind === "proposal") {
|
|
13552
14220
|
const proposalId = typeof legacy.proposal_id === "string" ? legacy.proposal_id : "wrp_unknown";
|
|
13553
14221
|
const targetType = typeof target?.type === "string" ? target.type : capability?.target.table ?? "object";
|
|
13554
14222
|
const targetId = target?.id !== void 0 ? String(target.id) : "unknown";
|
|
13555
|
-
const approval =
|
|
14223
|
+
const approval = isRecord5(legacy.approval) ? legacy.approval : void 0;
|
|
13556
14224
|
const state = typeof legacy.status === "string" ? legacy.status : "review_required";
|
|
13557
14225
|
const approvalRequired = legacy.approval_required !== false;
|
|
13558
14226
|
const executor = writebackExecutorName(legacy.writeback);
|
|
@@ -13568,7 +14236,7 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
|
13568
14236
|
id: proposalId,
|
|
13569
14237
|
state,
|
|
13570
14238
|
target: `${targetType}:${targetId}`,
|
|
13571
|
-
diff:
|
|
14239
|
+
diff: isRecord5(legacy.diff) ? legacy.diff : {},
|
|
13572
14240
|
approval_required: approvalRequired,
|
|
13573
14241
|
...approval ? { approval } : {},
|
|
13574
14242
|
writeback: {
|
|
@@ -13589,7 +14257,7 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
|
13589
14257
|
}
|
|
13590
14258
|
};
|
|
13591
14259
|
}
|
|
13592
|
-
const businessObject =
|
|
14260
|
+
const businessObject = isRecord5(legacy.business_object) ? legacy.business_object : void 0;
|
|
13593
14261
|
const objectType = typeof businessObject?.type === "string" ? businessObject.type : capability?.target.table ?? "record";
|
|
13594
14262
|
const objectId = businessObject?.id !== void 0 ? String(businessObject.id) : String(legacy.action ?? action);
|
|
13595
14263
|
return {
|
|
@@ -13597,7 +14265,7 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
|
13597
14265
|
summary: kind === "aggregate_read" ? `Read one reviewed aggregate through ${action}. Source member rows exposed: no. Source database changed: no.` : `Read ${objectType} ${objectId} through ${action}. Source database changed: no.`,
|
|
13598
14266
|
action,
|
|
13599
14267
|
kind,
|
|
13600
|
-
data:
|
|
14268
|
+
data: isRecord5(legacy.data) ? legacy.data : {},
|
|
13601
14269
|
proposal: null,
|
|
13602
14270
|
error: null,
|
|
13603
14271
|
evidence: evidenceBundleId ? evidenceHandle(evidenceBundleId) : null,
|
|
@@ -13611,7 +14279,7 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
|
|
|
13611
14279
|
};
|
|
13612
14280
|
}
|
|
13613
14281
|
function writebackExecutorName(value) {
|
|
13614
|
-
if (!
|
|
14282
|
+
if (!isRecord5(value)) return void 0;
|
|
13615
14283
|
return typeof value.executor === "string" ? value.executor : typeof value.mode === "string" ? value.mode : void 0;
|
|
13616
14284
|
}
|
|
13617
14285
|
function capabilityWritebackMode2(capability) {
|
|
@@ -13645,7 +14313,7 @@ function assertProposalWritebackResolvable(config, capability) {
|
|
|
13645
14313
|
throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but has no executor name.`);
|
|
13646
14314
|
}
|
|
13647
14315
|
const executor = config.executors?.[executorName];
|
|
13648
|
-
if (!
|
|
14316
|
+
if (!isRecord5(executor)) {
|
|
13649
14317
|
throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but executor ${executorName} is not configured.`);
|
|
13650
14318
|
}
|
|
13651
14319
|
if (executor.type !== "http_handler" && executor.type !== "command_handler") {
|
|
@@ -13912,26 +14580,26 @@ function errorChain(error) {
|
|
|
13912
14580
|
while (current !== void 0 && current !== null && chain.length < 6 && !seen.has(current)) {
|
|
13913
14581
|
chain.push(current);
|
|
13914
14582
|
seen.add(current);
|
|
13915
|
-
current =
|
|
14583
|
+
current = isRecord5(current) ? current.cause : void 0;
|
|
13916
14584
|
}
|
|
13917
14585
|
return chain;
|
|
13918
14586
|
}
|
|
13919
14587
|
function errorStringProperty(error, property) {
|
|
13920
|
-
if (!
|
|
14588
|
+
if (!isRecord5(error)) return void 0;
|
|
13921
14589
|
const value = error[property];
|
|
13922
14590
|
if (typeof value !== "string" && typeof value !== "number") return void 0;
|
|
13923
14591
|
const normalized = String(value).trim().toUpperCase();
|
|
13924
14592
|
return normalized || void 0;
|
|
13925
14593
|
}
|
|
13926
14594
|
function errorNumberProperty(error, property) {
|
|
13927
|
-
if (!
|
|
14595
|
+
if (!isRecord5(error)) return void 0;
|
|
13928
14596
|
const value = error[property];
|
|
13929
14597
|
const normalized = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
13930
14598
|
return Number.isInteger(normalized) ? normalized : void 0;
|
|
13931
14599
|
}
|
|
13932
14600
|
function errorMessage(error) {
|
|
13933
14601
|
if (error instanceof Error) return error.message;
|
|
13934
|
-
if (
|
|
14602
|
+
if (isRecord5(error) && typeof error.message === "string") return error.message;
|
|
13935
14603
|
return typeof error === "string" ? error : "";
|
|
13936
14604
|
}
|
|
13937
14605
|
function effectivePrincipalScope(config, capability, context) {
|
|
@@ -14680,7 +15348,7 @@ function validateToolArgs(capability, args) {
|
|
|
14680
15348
|
if (!Array.isArray(value)) throw new McpRuntimeError("ARGUMENT_TYPE_INVALID", `${name} must be an array of reviewed objects.`);
|
|
14681
15349
|
if (value.length < 1 || value.length > spec.max_items) throw new McpRuntimeError("ARGUMENT_ITEM_COUNT_INVALID", `${name} must contain 1 through ${spec.max_items} items.`);
|
|
14682
15350
|
for (const [index, item] of value.entries()) {
|
|
14683
|
-
if (!
|
|
15351
|
+
if (!isRecord5(item)) throw new McpRuntimeError("ARGUMENT_ITEM_TYPE_INVALID", `${name}[${index}] must be an object.`);
|
|
14684
15352
|
for (const key of Object.keys(item)) if (!Object.prototype.hasOwnProperty.call(spec.fields, key)) throw new McpRuntimeError("ARGUMENT_ITEM_FIELD_NOT_ALLOWED", `${name}[${index}].${key} is not a reviewed item field.`);
|
|
14685
15353
|
for (const [fieldName, fieldSpec] of Object.entries(spec.fields)) validateScalarArg(`${name}[${index}].${fieldName}`, fieldSpec, item[fieldName]);
|
|
14686
15354
|
}
|
|
@@ -14782,7 +15450,7 @@ function buildPatch(capability, args) {
|
|
|
14782
15450
|
function batchItemsFromArgs(capability, args) {
|
|
14783
15451
|
const argumentName = capability.operation?.batch?.items_from_arg;
|
|
14784
15452
|
const value = argumentName ? args[argumentName] : void 0;
|
|
14785
|
-
if (!argumentName || !Array.isArray(value) || value.some((item) => !
|
|
15453
|
+
if (!argumentName || !Array.isArray(value) || value.some((item) => !isRecord5(item))) {
|
|
14786
15454
|
throw new McpRuntimeError("BATCH_ITEMS_REQUIRED", `Bounded INSERT capability ${capability.name} requires its reviewed object-array argument.`);
|
|
14787
15455
|
}
|
|
14788
15456
|
return value;
|
|
@@ -15020,7 +15688,7 @@ function stableId(prefix, input) {
|
|
|
15020
15688
|
function hashJson(input) {
|
|
15021
15689
|
return `sha256:${crypto3.createHash("sha256").update(JSON.stringify(input)).digest("hex")}`;
|
|
15022
15690
|
}
|
|
15023
|
-
function
|
|
15691
|
+
function isRecord5(value) {
|
|
15024
15692
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15025
15693
|
}
|
|
15026
15694
|
function toolErrorPayload(error) {
|