UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

542 lines (541 loc) 21 kB
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import { t as safeEqualSecret } from "./secret-equal-DRsL8lKD.js"; import { s as runWithSessionMcpRequestSignal } from "./mcp-app-model-context-dSmaJhyr.js"; import { s as peekSessionMcpRuntime } from "./agent-bundle-mcp-manager-api-DCHQm4w1.js"; import { c as resolveMcpAppSandboxPort, i as getMcpAppViewLease, s as buildMcpAppSandboxPath } from "./mcp-ui-resource-BIyljeqN.js"; import { i as readJsonBodyOrError, m as watchClientDisconnect, s as sendJson, y as respondPlainText } from "./http-common-BaZaosnr.js"; import { a as classifyMcpAppStandalonePath, n as MCP_APP_STANDALONE_VIEW_PATH, t as MCP_APP_STANDALONE_PATH } from "./gateway-http-route-contracts-jBtizxKU.js"; import { i as requireMcpAppInteraction, n as executeMcpAppOperation, r as parseMcpAppOperation, s as withMcpAppActiveView } from "./mcp-app-operations-CnqbIGIv.js"; import { createHash, createHmac, randomBytes } from "node:crypto"; //#region src/gateway/mcp-app-standalone-host.ts function runStandaloneMcpAppHost(config) { const browser = globalThis; const host = browser.document.getElementById("host"); const ticket = browser.location.hash.startsWith("#") ? browser.location.hash.slice(1) : ""; let frame; let payload; let initializeAccepted = false; let initialized = false; let requestId = 0; let sandboxOrigin; let teardownId; let phase = "active"; const pending = /* @__PURE__ */ new Map(); const asStandaloneRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : void 0; const errorMessage = (error, timeoutMessage) => asStandaloneRecord(error)?.name === "TimeoutError" ? timeoutMessage : error instanceof Error ? error.message : String(error); const fail = (message) => { if (phase === "suspended" || phase === "closed") return; removeFrame(); host?.replaceChildren(Object.assign(browser.document.createElement("p"), { className: "error", textContent: message })); }; const post = (message) => { if (sandboxOrigin) frame?.contentWindow?.postMessage(message, sandboxOrigin); }; const notify = (method, params = {}) => post({ jsonrpc: "2.0", method, params }); const respond = (id, result) => post({ jsonrpc: "2.0", id, result }); const reject = (id, code, message) => post({ jsonrpc: "2.0", id, error: { code, message } }); const retireOperations = () => { for (const [id, controller] of pending) { pending.delete(id); controller.abort(); } }; const removeFrame = () => { phase = "closed"; retireOperations(); frame?.remove(); frame = void 0; sandboxOrigin = void 0; teardownId = void 0; }; const resolveSandboxUrl = (view) => { const base = view.sandboxOrigin ? new URL(view.sandboxOrigin) : new URL(browser.location.origin); if (!view.sandboxOrigin) base.port = String(view.sandboxPort); base.pathname = "/"; base.search = ""; base.hash = ""; const resolved = new URL(view.sandboxUrl, base); if (!["http:", "https:"].includes(resolved.protocol) || resolved.origin !== base.origin || resolved.origin === browser.location.origin || resolved.pathname !== "/mcp-app-sandbox") throw new Error("MCP App sandbox URL is invalid"); return resolved; }; const request = async (method, params, signal) => { const response = await fetch(config.viewPath, { method: "POST", headers: { Authorization: `MCP-App ${ticket}`, "Content-Type": "application/json" }, body: JSON.stringify({ method, params }), cache: "no-store", credentials: "omit", signal }); const body = await response.json().catch(() => void 0); signal.throwIfAborted(); if (response.status === 401) { fail("MCP App ticket was rejected"); throw new Error("MCP App ticket was rejected"); } if (!response.ok || body?.ok !== true) throw new Error(body?.error || "MCP App operation was rejected"); return body.result; }; const operations = /* @__PURE__ */ new Set(); const installOperations = (view) => { if (view.serverTools === true) { operations.add("tools/call"); operations.add("tools/list"); } if (view.serverResources === true) { operations.add("resources/list"); operations.add("resources/templates/list"); operations.add("resources/read"); } }; const deliverInitialState = () => { if (initialized || !payload) return; initialized = true; notify("ui/notifications/tool-input", { arguments: asStandaloneRecord(payload.toolInput) ?? {} }); notify("ui/notifications/tool-result", payload.toolResult); }; const isValidInitialize = (params) => { const record = asStandaloneRecord(params); const appInfo = asStandaloneRecord(record?.appInfo); return typeof record?.protocolVersion === "string" && typeof appInfo?.name === "string" && typeof appInfo?.version === "string" && asStandaloneRecord(record?.appCapabilities) !== void 0; }; browser.addEventListener("message", (event) => { const message = asStandaloneRecord(event.data); if (event.source !== frame?.contentWindow || event.origin !== sandboxOrigin || message?.jsonrpc !== "2.0" || message.id !== void 0 && typeof message.id !== "string" && !(typeof message.id === "number" && Number.isInteger(message.id))) return; if (message.method === "notifications/cancelled") { const id = asStandaloneRecord(message.params)?.requestId; if (message.id === void 0 && (typeof id === "string" || typeof id === "number")) { const controller = pending.get(id); pending.delete(id); controller?.abort(); } return; } if (typeof message.method === "string" && message.id !== void 0 && pending.has(message.id)) return; if (message.method === "ui/notifications/sandbox-proxy-ready") { if (payload) notify("ui/notifications/sandbox-resource-ready", { html: payload.html, csp: payload.csp }); return; } if (message.method === "ping" && message.id !== void 0) { respond(message.id, {}); return; } if (message.method === "ui/initialize" && message.id !== void 0) { if (!payload || !isValidInitialize(message.params)) { reject(message.id, -32602, "Invalid MCP App initialization"); return; } initializeAccepted = true; respond(message.id, { protocolVersion: config.protocolVersion, hostInfo: { name: "OpenClaw standalone host", version: "1.0.0" }, hostCapabilities: { sandbox: { csp: payload.csp ?? {} }, ...payload.serverTools === true ? { serverTools: {} } : {}, ...payload.serverResources === true ? { serverResources: {} } : {} }, hostContext: { theme: browser.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light", displayMode: "inline", availableDisplayModes: ["inline"], containerDimensions: { width: Math.max(1, browser.innerWidth), height: 600 }, locale: browser.navigator.language, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, platform: "web" } }); return; } if (message.method === "ui/notifications/initialized") { if (!initializeAccepted) return; deliverInitialState(); return; } if (message.method === "ui/notifications/size-changed") { const height = asStandaloneRecord(message.params)?.height; if (frame && typeof height === "number" && Number.isFinite(height)) frame.style.height = `${Math.min(1200, Math.max(160, Math.round(height)))}px`; return; } if (message.method === "ui/notifications/request-teardown") { if (phase !== "active") return; phase = "tearing-down"; const id = ++requestId; teardownId = id; post({ jsonrpc: "2.0", id, method: "ui/resource-teardown", params: {} }); setTimeout(() => { if (teardownId === id) removeFrame(); }, 1e3); return; } if (teardownId !== void 0 && message.id === teardownId && message.method === void 0) { removeFrame(); return; } if (phase === "suspended" || phase === "closed" || message.id === void 0 || typeof message.method !== "string") return; if (!operations.has(message.method)) { reject(message.id, -32601, `Method not available in standalone host: ${message.method}`); return; } if (!initialized) { reject(message.id, -32002, "MCP App initialization is incomplete"); return; } const id = message.id; const controller = new AbortController(); pending.set(id, controller); request(message.method, message.params ?? {}, controller.signal).then((result) => { if (pending.get(id) === controller) respond(id, result); }).catch((error) => { if (pending.get(id) === controller) reject(id, -32e3, errorMessage(error, "MCP App operation timed out; try again")); }).finally(() => { if (pending.get(id) === controller) pending.delete(id); }); }); browser.addEventListener("pagehide", (event) => { if (phase === "tearing-down") phase = "closed"; else if (phase === "active") phase = event.persisted ? "suspended" : "closed"; retireOperations(); if (frame?.contentWindow) post({ jsonrpc: "2.0", id: ++requestId, method: "ui/resource-teardown", params: {} }); }); browser.addEventListener("pageshow", (event) => { if (event.persisted && phase === "suspended") { removeFrame(); browser.location.reload(); } }); if (!ticket) { fail("MCP App ticket is missing"); return; } fetch(config.viewPath, { headers: { Authorization: `MCP-App ${ticket}` }, cache: "no-store", credentials: "omit", signal: AbortSignal.timeout(config.initialLoadTimeoutMs) }).then(async (response) => { if (!response.ok) throw new Error("MCP App ticket was rejected"); const view = await response.json(); if (phase !== "active") return; payload = view; installOperations(payload); const sandboxUrl = resolveSandboxUrl(payload); sandboxOrigin = sandboxUrl.origin; frame = browser.document.createElement("iframe"); frame.title = "MCP App"; frame.referrerPolicy = "origin"; frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); frame.src = sandboxUrl.href; host?.replaceChildren(frame); }).catch((error) => fail(errorMessage(error, "MCP App view timed out; reload to try again"))); } //#endregion //#region src/gateway/mcp-app-standalone.ts const MCP_APP_STANDALONE_TICKET_SCOPE = "mcp-app-standalone-view"; const MCP_APP_STANDALONE_INITIAL_LOAD_TIMEOUT_MS = 3e4; const MCP_APP_STANDALONE_TICKET_TTL_MS = 12e4; const MCP_APP_STANDALONE_TICKET_MIN_REMAINING_MS = 15e3; const MCP_APP_STANDALONE_TICKET_MAX_ENTRIES = 256; const MCP_APP_STABLE_PROTOCOL_VERSION = "2026-01-26"; const MCP_APP_OPERATION_MAX_BODY_BYTES = 262144; const ticketSecret = randomBytes(32); const ticketBindings = /* @__PURE__ */ new Map(); const mcpAppStandaloneTesting = { clearTickets: () => ticketBindings.clear() }; function pruneTicketBindings(nowMs) { for (const [nonce, binding] of ticketBindings) if (binding.expiresAtMs <= nowMs) ticketBindings.delete(nonce); } function signTicket(nonce, expiresAtMs, secret) { return createHmac("sha256", secret).update(`${MCP_APP_STANDALONE_TICKET_SCOPE}\0${nonce}\0${expiresAtMs}`).digest("base64url"); } function formatTicket(binding, secret) { return `v1.${binding.nonce}.${binding.expiresAtMs}.${signTicket(binding.nonce, binding.expiresAtMs, secret)}`; } function createMcpAppStandaloneTicket(params) { const nowMs = params.nowMs ?? Date.now(); if (!Number.isSafeInteger(nowMs) || params.view.expiresAtMs <= nowMs) return; const expiresAtMs = Math.min(params.view.expiresAtMs, nowMs + MCP_APP_STANDALONE_TICKET_TTL_MS); pruneTicketBindings(nowMs); let reusable; for (const binding of ticketBindings.values()) if (binding.sessionKey === params.sessionKey && binding.sessionId === params.view.sessionId && binding.viewId === params.view.viewId) { if (binding.expiresAtMs > params.view.expiresAtMs) { ticketBindings.delete(binding.nonce); continue; } if (!reusable || binding.expiresAtMs > reusable.expiresAtMs) reusable = binding; } if (reusable && (reusable.expiresAtMs >= expiresAtMs || reusable.expiresAtMs - nowMs >= MCP_APP_STANDALONE_TICKET_MIN_REMAINING_MS)) { const ticket = formatTicket(reusable, params.secret ?? ticketSecret); return { ticket, url: `${MCP_APP_STANDALONE_PATH}#${ticket}`, expiresAtMs: reusable.expiresAtMs }; } if (ticketBindings.size >= MCP_APP_STANDALONE_TICKET_MAX_ENTRIES) return; const nonce = randomBytes(24).toString("base64url"); const binding = { nonce, sessionKey: params.sessionKey, sessionId: params.view.sessionId, viewId: params.view.viewId, expiresAtMs }; ticketBindings.set(nonce, binding); const ticket = formatTicket(binding, params.secret ?? ticketSecret); return { ticket, url: `${MCP_APP_STANDALONE_PATH}#${ticket}`, expiresAtMs }; } function verifyMcpAppStandaloneTicket(value, expected = {}) { const nowMs = expected.nowMs ?? Date.now(); if (!Number.isSafeInteger(nowMs)) return; const parts = value.split("."); if (parts.length !== 4 || parts[0] !== "v1") return; const [, nonce, rawExpiresAtMs, signature] = parts; if (!nonce || nonce.length !== 32 || !rawExpiresAtMs || !signature) return; const expiresAtMs = Number(rawExpiresAtMs); if (!Number.isSafeInteger(expiresAtMs) || expiresAtMs <= nowMs) return; const expectedSignature = signTicket(nonce, expiresAtMs, expected.secret ?? ticketSecret); if (!safeEqualSecret(signature, expectedSignature)) return; const binding = ticketBindings.get(nonce); if (!binding || binding.expiresAtMs !== expiresAtMs || expected.sessionKey !== void 0 && binding.sessionKey !== expected.sessionKey || expected.sessionId !== void 0 && binding.sessionId !== expected.sessionId || expected.viewId !== void 0 && binding.viewId !== expected.viewId) return; return binding; } function resolveTicketActiveView(value, nowMs, secret) { const binding = verifyMcpAppStandaloneTicket(value, { nowMs, secret }); if (!binding) return; const runtime = peekSessionMcpRuntime({ sessionKey: binding.sessionKey }); if (!runtime || runtime.mcpAppsEnabled !== true || runtime.sessionId !== binding.sessionId) return; const view = getMcpAppViewLease(binding.viewId, runtime); if (!view || view.viewId !== binding.viewId || view.sessionId !== binding.sessionId || view.expiresAtMs <= nowMs || binding.expiresAtMs > view.expiresAtMs) return; return { runtime, view }; } function ticketFromRequest(req) { const authorization = req.headers.authorization; if (!authorization?.startsWith("MCP-App ")) return; return authorization.slice(8).trim() || void 0; } function supportsStandaloneToolOperations(view) { return view.allowedAppToolNames !== void 0 && view.readOnly !== true; } async function supportsStandaloneResourceOperations(view) { try { await requireMcpAppInteraction(view); return true; } catch { return false; } } function sendJsonRepresentation(req, res, statusCode, body) { const serialized = JSON.stringify(body); res.statusCode = statusCode; res.setHeader("Content-Type", "application/json; charset=utf-8"); res.setHeader("Content-Length", String(Buffer.byteLength(serialized))); res.end(req.method === "HEAD" ? void 0 : serialized); } function standaloneHostHtml() { const serializedConfig = JSON.stringify({ protocolVersion: MCP_APP_STABLE_PROTOCOL_VERSION, viewPath: MCP_APP_STANDALONE_VIEW_PATH, initialLoadTimeoutMs: MCP_APP_STANDALONE_INITIAL_LOAD_TIMEOUT_MS }); const escapedSource = `;(() => { const __name = (target) => target; (${runStandaloneMcpAppHost.toString()})(${serializedConfig}); })();`.replaceAll("<\/script", "<\\/script"); return { html: `<!doctype html> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <title>OpenClaw MCP App</title> <style>html,body{height:100%;margin:0;background:#fff;color:#111;font:14px system-ui,sans-serif}main{height:100%}iframe{display:block;width:100%;height:600px;border:0}.error{padding:16px;color:#b91c1c}</style> <main id="host" aria-live="polite"></main> <script>${escapedSource}<\/script>`, scriptHash: createHash("sha256").update(escapedSource).digest("base64") }; } function resolveShellSandboxOrigin(params) { if (params.sandboxOrigin) return new URL(params.sandboxOrigin).origin; const protocol = "encrypted" in params.req.socket && params.req.socket.encrypted ? "https:" : "http:"; const base = new URL(`${protocol}//${params.req.headers.host ?? "localhost"}`); base.port = String(params.sandboxPort); return base.origin; } async function handleMcpAppStandaloneHttpRequest(req, res, options = {}) { let url; try { url = new URL(req.url ?? "/", "http://localhost"); } catch { return false; } const route = classifyMcpAppStandalonePath(url.pathname); if (route === "namespace" || route === "outside") return false; if (req.method !== "GET" && req.method !== "HEAD" && !(url.pathname === MCP_APP_STANDALONE_VIEW_PATH && req.method === "POST")) { respondPlainText(res, 404, "Not Found"); return true; } const gatewayPort = options.gatewayPort ?? req.socket.localPort; if (!gatewayPort) { respondPlainText(res, 503, "MCP App host unavailable"); return true; } let sandboxPort; try { sandboxPort = resolveMcpAppSandboxPort(gatewayPort, options.sandboxPort); } catch { respondPlainText(res, 503, "MCP App host unavailable"); return true; } res.setHeader("Cache-Control", "no-store"); res.setHeader("Referrer-Policy", "no-referrer"); res.setHeader("X-Content-Type-Options", "nosniff"); if (route === "shell") { const frameOrigin = resolveShellSandboxOrigin({ req, sandboxOrigin: options.sandboxOrigin, sandboxPort }); const shell = standaloneHostHtml(); res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Content-Length", String(Buffer.byteLength(shell.html))); res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'sha256-${shell.scriptHash}'; style-src 'unsafe-inline'; connect-src 'self'; frame-src ${frameOrigin}; base-uri 'none'; form-action 'none'; object-src 'none'`); res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); res.end(req.method === "HEAD" ? void 0 : shell.html); return true; } res.setHeader("Vary", "Authorization"); const ticket = ticketFromRequest(req); const now = options.now ?? (() => options.nowMs ?? Date.now()); const nowMs = now(); const secret = options.ticketSecret ?? ticketSecret; const active = ticket ? resolveTicketActiveView(ticket, nowMs, secret) : void 0; if (!active) { res.setHeader("WWW-Authenticate", "MCP-App"); respondPlainText(res, 401, "Unauthorized"); return true; } if (req.method === "POST") { const controller = new AbortController(); const stopWatching = watchClientDisconnect(req, res, controller); try { await runWithSessionMcpRequestSignal(controller.signal, async () => { controller.signal.throwIfAborted(); const body = await readJsonBodyOrError(req, res, MCP_APP_OPERATION_MAX_BODY_BYTES); controller.signal.throwIfAborted(); if (body === void 0) return; const operation = parseMcpAppOperation(body); if (!operation) { sendJson(res, 400, { ok: false, error: "Invalid MCP App operation" }); return; } const current = ticket ? resolveTicketActiveView(ticket, now(), secret) : void 0; if (!current) { res.setHeader("WWW-Authenticate", "MCP-App"); sendJson(res, 401, { ok: false, error: "Unauthorized" }); return; } if ((operation.method === "tools/call" || operation.method === "tools/list") && !supportsStandaloneToolOperations(current.view)) { sendJson(res, 403, { ok: false, error: "MCP App tool bridge is unavailable" }); return; } const result = await executeMcpAppOperation(current, operation); controller.signal.throwIfAborted(); sendJson(res, 200, { ok: true, result }); }); } catch (error) { if (!controller.signal.aborted && !res.destroyed) sendJson(res, 403, { ok: false, error: formatErrorMessage(error) }); } finally { stopWatching(); } return true; } try { return await withMcpAppActiveView(active, "read", async () => { const { runtime, view } = active; const serverResources = runtime.readResource !== void 0 && await supportsStandaloneResourceOperations(view); sendJsonRepresentation(req, res, 200, { sandboxUrl: buildMcpAppSandboxPath(view.csp), sandboxPort, ...options.sandboxOrigin ? { sandboxOrigin: new URL(options.sandboxOrigin).origin } : {}, html: view.html, ...view.csp ? { csp: view.csp } : {}, toolInput: view.toolInput, toolResult: view.toolResult, serverTools: supportsStandaloneToolOperations(view), serverResources }); return true; }); } catch (error) { sendJsonRepresentation(req, res, 429, { ok: false, error: formatErrorMessage(error) }); return true; } } //#endregion export { verifyMcpAppStandaloneTicket as i, handleMcpAppStandaloneHttpRequest as n, mcpAppStandaloneTesting as r, createMcpAppStandaloneTicket as t };