UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

269 lines (268 loc) 11.8 kB
import { c as isRecord } from "./record-coerce-DItp3I4t.js"; import { l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js"; import { i as registerSecretValueForRedaction } from "./secret-redaction-registry-BOLC6DkF.js"; import { i as resolveOpenClawMcpTransportAlias } from "./mcp-config-normalize-CWrDNtye.js"; import { i as logWarn } from "./logger-DwECwNVZ.js"; import { u as getActivePluginRegistry } from "./runtime-BL4wZfTq.js"; import { a as getPluginRuntimeGatewayRequestScope } from "./gateway-request-scope-BCMYlsDI.js"; import crypto from "node:crypto"; //#region src/agents/mcp-connection-resolver.ts /** * Plugin-registered MCP connection resolvers: lookup and per-requester resolve. * Resolved url/headers are credentials — never log, fingerprint, or persist them. */ /** Per-server bound on plugin resolve(); stalled providers must not hang getOrCreate. */ const MCP_CONNECTION_RESOLVER_TIMEOUT_MS = 1e4; /** * How long a full-set requester runtime may skip re-resolve while active. * Revocation/rotation takes effect within this window even for continuously active requesters. */ const MCP_CONNECTION_REVALIDATE_MS = 3e5; const MCP_CONNECTION_RESOLVER_TEST_STATE_KEY = Symbol.for("openclaw.mcpServerConnectionResolverTestState"); function getTestState() { const globalStore = globalThis; const existing = globalStore[MCP_CONNECTION_RESOLVER_TEST_STATE_KEY]; if (existing) return existing; const state = {}; globalStore[MCP_CONNECTION_RESOLVER_TEST_STATE_KEY] = state; return state; } function resolveConnectionResolverTimeoutMs() { const override = getTestState().resolveTimeoutMs; if (typeof override === "number" && Number.isFinite(override) && override > 0) return Math.floor(override); return MCP_CONNECTION_RESOLVER_TIMEOUT_MS; } function resolveMcpConnectionRevalidateMs() { const override = getTestState().revalidateMs; if (typeof override === "number" && Number.isFinite(override) && override > 0) return Math.floor(override); return MCP_CONNECTION_REVALIDATE_MS; } /** * Ephemeral per-process HMAC key for connection digests. Never exported, logged, * or persisted — dies with the process so digests are not offline-guessable. */ let connectionDigestKey; function getConnectionDigestKey() { connectionDigestKey ??= crypto.randomBytes(32); return connectionDigestKey; } /** * Ephemeral keyed digest of resolved connection material for rotation detection. * HMAC-SHA256 with a process-local random key — not a plain hash of credentials. * Never log or persist the preimage (urls/headers) or the key. */ function hashMcpResolvedConnections(connections) { const tuples = [...connections.entries()].toSorted(([a], [b]) => a.localeCompare(b)).map(([serverName, connection]) => { const headers = connection.headers ? Object.entries(connection.headers).toSorted(([a], [b]) => a.localeCompare(b)) : []; return [ serverName, connection.url, headers ]; }); return crypto.createHmac("sha256", getConnectionDigestKey()).update(JSON.stringify(tuples)).digest("hex"); } var McpResolverTimeoutError = class extends Error { constructor() { super("mcp connection resolver timed out"); this.name = "McpResolverTimeoutError"; } }; function raceWithTimeout(promise, timeoutMs) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new McpResolverTimeoutError()); }, timeoutMs); timer.unref?.(); promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error instanceof Error ? error : new Error(String(error))); }); }); } /** Returns registered connection resolvers keyed by server name (deterministic order). */ function listMcpServerConnectionResolversByServerName() { const testOverrides = getTestState().resolversByServerName; if (testOverrides) return new Map([...testOverrides.entries()].toSorted(([a], [b]) => a.localeCompare(b))); const byName = /* @__PURE__ */ new Map(); const registry = getPluginRuntimeGatewayRequestScope()?.pluginRegistry ?? getActivePluginRegistry(); for (const entry of registry?.mcpServerConnectionResolvers ?? []) { const serverName = normalizeOptionalString(entry.resolver.serverName); if (!serverName || typeof entry.resolver.resolve !== "function" || byName.has(serverName)) continue; byName.set(serverName, { pluginId: entry.pluginId, serverName, resolve: entry.resolver.resolve }); } return new Map([...byName.entries()].toSorted(([a], [b]) => a.localeCompare(b))); } /** Partition loaded MCP servers into static vs requester-scoped connections. */ function partitionMcpServersByConnectionScope(mcpServers) { const resolvers = listMcpServerConnectionResolversByServerName(); const staticServerEntries = []; const requesterScopedServerNames = []; const oauthRequesterServerNames = []; const resolverRequesterServerNames = []; for (const [serverName, rawServer] of Object.entries(mcpServers).toSorted(([a], [b]) => a.localeCompare(b))) { const oauth = isRecord(rawServer) && isRecord(rawServer.oauth) ? rawServer.oauth : void 0; if (isRecord(rawServer) && rawServer.auth === "oauth" && oauth?.identity === "per-requester") { requesterScopedServerNames.push(serverName); oauthRequesterServerNames.push(serverName); continue; } if (resolvers.has(serverName)) { requesterScopedServerNames.push(serverName); resolverRequesterServerNames.push(serverName); continue; } staticServerEntries.push([serverName, rawServer]); } return { staticServers: Object.fromEntries(staticServerEntries), requesterScopedServerNames, oauthRequesterServerNames, resolverRequesterServerNames }; } /** * Debug-proxy capture and log redaction match registered exact values, not * header names alone. Resolver output is credential material (auth headers, * signed-URL query tokens), so register it before it can reach any transport. */ function registerResolvedConnectionSecrets(connection) { for (const value of Object.values(connection.headers ?? {})) { registerSecretValueForRedaction(value); const bareToken = value.trim().split(/\s+/).at(-1); if (bareToken && bareToken !== value) registerSecretValueForRedaction(bareToken); } registerSecretValueForRedaction(connection.url); try { const url = new URL(connection.url); for (const queryValue of url.searchParams.values()) registerSecretValueForRedaction(queryValue); if (url.password) registerSecretValueForRedaction(url.password); } catch {} } /** * Resolve requester-scoped server connections. Fail closed without requesterSenderId: * returns an empty map (no shared-connection fallback). Per-server resolve errors and * timeouts are logged generically and omitted so one plugin cannot block static MCP. * Servers resolve concurrently (each individually bounded). */ async function resolveRequesterScopedMcpConnections(params) { const requesterSenderId = normalizeOptionalString(params.requesterSenderId); const resolved = /* @__PURE__ */ new Map(); if (!requesterSenderId || params.serverNames.length === 0) return resolved; const resolvers = listMcpServerConnectionResolversByServerName(); const ctx = { requesterSenderId, ...normalizeOptionalString(params.agentAccountId) ? { agentAccountId: normalizeOptionalString(params.agentAccountId) } : {}, ...normalizeOptionalString(params.messageChannel) ? { messageChannel: normalizeOptionalString(params.messageChannel) } : {} }; const timeoutMs = resolveConnectionResolverTimeoutMs(); const sortedNames = [...params.serverNames].toSorted((a, b) => a.localeCompare(b)); const settled = await Promise.all(sortedNames.map(async (serverName) => { const entry = resolvers.get(serverName); if (!entry) return null; try { const result = await raceWithTimeout(Promise.resolve(entry.resolve(ctx)), timeoutMs); if (!result || typeof result.url !== "string" || result.url.trim().length === 0) return null; const headers = result.headers && isRecord(result.headers) ? Object.fromEntries(Object.entries(result.headers).filter((headerEntry) => typeof headerEntry[1] === "string").toSorted(([a], [b]) => a.localeCompare(b))) : void 0; const connection = { url: result.url.trim(), ...headers && Object.keys(headers).length > 0 ? { headers } : {} }; registerResolvedConnectionSecrets(connection); return { serverName, connection }; } catch (error) { const kind = error instanceof McpResolverTimeoutError ? "resolver timeout" : "resolver error"; logWarn(`bundle-mcp: connection resolver for server "${serverName}" (plugin "${entry.pluginId}") failed with ${kind}`); return null; } })); for (const entry of settled) if (entry) resolved.set(entry.serverName, entry.connection); return resolved; } /** * Apply resolved connection fields for transport construction only. * Does not mutate the original static config object. */ function applyMcpConnectionOverride(rawServer, override) { const base = isRecord(rawServer) ? { ...rawServer } : {}; base.url = override.url; if (override.headers) base.headers = { ...override.headers }; else delete base.headers; const fromTransport = typeof base.transport === "string" ? resolveOpenClawMcpTransportAlias(base.transport) : void 0; const fromType = resolveOpenClawMcpTransportAlias(base.type); base.transport = fromTransport ?? fromType ?? "streamable-http"; delete base.auth; delete base.oauth; delete base.type; delete base.command; delete base.args; return base; } /** * Fingerprint shape for requester-scoped servers: identity + filters only. * Never includes resolved or static url/headers credentials. */ function redactMcpServersForFingerprint(mcpServers, requesterScopedServerNames) { const redacted = {}; for (const [serverName, rawServer] of Object.entries(mcpServers).toSorted(([a], [b]) => a.localeCompare(b))) { if (!requesterScopedServerNames.has(serverName)) { redacted[serverName] = rawServer; continue; } if (!isRecord(rawServer)) { redacted[serverName] = { connection: "requester-scoped" }; continue; } const { url: _url, headers: _headers, command: _command, args: _args, env: _env, ...rest } = rawServer; redacted[serverName] = { ...rest, connection: "requester-scoped" }; } return redacted; } function buildMcpRequesterRuntimeCacheKey(params) { return JSON.stringify({ sessionId: params.sessionId, messageChannel: normalizeOptionalString(params.messageChannel) ?? "", agentAccountId: normalizeOptionalString(params.agentAccountId) ?? "", requesterSenderId: params.requesterSenderId }); } const testing = { setMcpServerConnectionResolversForTest(resolvers) { if (!resolvers) { getTestState().resolversByServerName = void 0; return; } const map = /* @__PURE__ */ new Map(); for (const resolver of resolvers) { const serverName = normalizeOptionalString(resolver.serverName); if (!serverName || typeof resolver.resolve !== "function") continue; map.set(serverName, { pluginId: normalizeOptionalString(resolver.pluginId) ?? "test-plugin", serverName, resolve: resolver.resolve }); } getTestState().resolversByServerName = map; }, setMcpConnectionResolverTimeoutMsForTest(timeoutMs) { getTestState().resolveTimeoutMs = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.floor(timeoutMs) : void 0; }, setMcpConnectionRevalidateMsForTest(revalidateMs) { getTestState().revalidateMs = typeof revalidateMs === "number" && Number.isFinite(revalidateMs) && revalidateMs > 0 ? Math.floor(revalidateMs) : void 0; } }; //#endregion export { redactMcpServersForFingerprint as a, testing as c, partitionMcpServersByConnectionScope as i, buildMcpRequesterRuntimeCacheKey as n, resolveMcpConnectionRevalidateMs as o, hashMcpResolvedConnections as r, resolveRequesterScopedMcpConnections as s, applyMcpConnectionOverride as t };