UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

209 lines (208 loc) • 9.27 kB
import { l as normalizeOptionalString } from "./string-coerce-1VuiLaaR.mjs"; import { c as isRecord, s as filterStringRecord } from "./record-coerce-TIsquoVR.mjs"; import { s as raceWithTimeout } from "./src-D1BHGNTj.mjs"; import { s as registerSecretValueForRedaction } from "./secret-redaction-registry-DorVHn0l.mjs"; import { a as getPluginRuntimeGatewayRequestScope } from "./gateway-request-scope-BSmCVVpP.mjs"; import { i as resolveConfiguredMcpTransport } from "./mcp-config-normalize-DQmvZkAl.mjs"; import { l as getActivePluginRegistry } from "./runtime-NsVZTERq.mjs"; import { i as logWarn } from "./logger-DskmrSVa.mjs"; 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; /** * 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; /** * 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", connectionDigestKey ??= crypto.randomBytes(32)).update(JSON.stringify(tuples)).digest("hex"); } var McpResolverTimeoutError = class extends Error { constructor() { super("mcp connection resolver timed out"); this.name = "McpResolverTimeoutError"; } }; /** Returns registered connection resolvers keyed by server name (deterministic order). */ function listMcpServerConnectionResolversByServerName() { 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 agentAccountId = normalizeOptionalString(params.agentAccountId); const messageChannel = normalizeOptionalString(params.messageChannel); const ctx = { requesterSenderId, ...agentAccountId ? { agentAccountId } : {}, ...messageChannel ? { messageChannel } : {} }; const timeoutMs = MCP_CONNECTION_RESOLVER_TIMEOUT_MS; 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, () => { throw new McpResolverTimeoutError(); }, { ref: false }); if (!result || typeof result.url !== "string" || result.url.trim().length === 0) return null; const filteredHeaders = filterStringRecord(result.headers); const headers = filteredHeaders ? Object.fromEntries(Object.entries(filteredHeaders).toSorted(([a], [b]) => a.localeCompare(b))) : void 0; const connection = { url: result.url.trim(), ...headers ? { 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 transport = resolveConfiguredMcpTransport(base); base.transport = !transport || transport === "stdio" ? "streamable-http" : transport; 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 }); } //#endregion export { partitionMcpServersByConnectionScope as a, hashMcpResolvedConnections as i, applyMcpConnectionOverride as n, redactMcpServersForFingerprint as o, buildMcpRequesterRuntimeCacheKey as r, resolveRequesterScopedMcpConnections as s, MCP_CONNECTION_REVALIDATE_MS as t };