UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

280 lines (279 loc) • 12.7 kB
import { a as asOptionalRecord } from "./record-coerce-TIsquoVR.mjs"; import { t as hasErrnoCode } from "./errno-FR4CRyya.mjs"; import { t as formatErrorMessage } from "./errors-O33D01XW.mjs"; import { t as logDebug } from "./logger-DskmrSVa.mjs"; import { d as retainGatewayDeviceRevocation } from "./device-revocation-DeateGFe.mjs"; import { i as getMcpAppViewLease } from "./mcp-ui-resource-BFfH0TzA.mjs"; import { i as loadGatewaySessionEntryReadOnly } from "./session-utils-store-BAlWJHht.mjs"; import "./session-utils-seGz8XhX.mjs"; import { t as WORKSPACE_PREVIEW_MAX_BYTES } from "./workspace-file-limits-loc-yzkf.mjs"; import { l as resolveWorkspacePath, m as updateWorkspaceFile, s as readWorkspaceFile } from "./workspace-fs-zorsuwri.mjs"; import { l as resolveMcpAppRequesterId, o as requireMcpAppInteraction } from "./mcp-app-operations-DR8z7nxr.mjs"; import { t as retainSessionScopedRead } from "./session-scoped-read-T8lBdRDm.mjs"; import { t as resolveLocalSessionWorkspaceRoot } from "./sessions-files-huv1OyNP.mjs"; import { stat, unwatchFile, watch, watchFile } from "node:fs"; import path from "node:path"; import { createHash, randomUUID } from "node:crypto"; //#region src/gateway/mcp-app-host-files.ts const writeAdmissions = /* @__PURE__ */ new WeakSet(); const subscriptions = /* @__PURE__ */ new WeakMap(); const etag = (bytes) => createHash("sha256").update(bytes).digest("hex"); /** Called only by the authenticated file entrypoint owner, never with an App-provided URI. */ async function prepareMcpAppHostFile(options, target) { const read = retainSessionScopedRead(options, target.sessionKey, target.agentId, { requireMaterialized: true }); try { const rootDir = resolveLocalSessionWorkspaceRoot(target); const loaded = loadGatewaySessionEntryReadOnly(target.sessionKey, { agentId: target.agentId }); if (!rootDir || !loaded.entry?.sessionId) throw new Error("Local workspace file access is unavailable"); const requesterId = resolveMcpAppRequesterId(options.client); const sessionId = loaded.entry.sessionId; const assertCurrent = () => { read?.assertCurrent(); if (options.hasCurrentClientAuthority?.() === false || resolveMcpAppRequesterId(options.client) !== requesterId || loadGatewaySessionEntryReadOnly(target.sessionKey, { agentId: target.agentId }).entry?.sessionId !== sessionId || resolveLocalSessionWorkspaceRoot(target) !== rootDir) throw new Error("App file workspace authority changed"); }; const resolved = resolveWorkspacePath(rootDir, target.path); if (!resolved) throw new Error("File is outside the session workspace"); const browserPath = path.relative(rootDir, resolved); const result = await readWorkspaceFile(rootDir, browserPath, { assertCurrent }); assertCurrent(); if (!result || result === "too-large") throw new Error("File is missing, unsafe, or too large"); return { resourceUri: "openclaw-file://" + randomUUID(), name: path.basename(result.canonicalPath), rootDir, path: result.canonicalPath, sessionId, requesterId }; } finally { read?.release(); } } async function withHostFile(options, view, uri, operation, mutation = false) { const file = view.hostFile; const sessionKey = view.runtime.sessionKey; if (!file || file.resourceUri !== uri || !sessionKey) throw new Error("Resource is not the file opened by this App"); const read = retainSessionScopedRead(options, sessionKey, view.agentId, { requireMaterialized: true }); const assertCurrent = () => { view.runtime.assertOwnerCurrent?.(); read?.assertCurrent(); options.sessionMutationAuthorization?.assertCurrent(); if (options.signal?.aborted || options.client?.connectionSignal?.aborted || options.hasCurrentClientAuthority?.() === false || getMcpAppViewLease(view.viewId, view.runtime) !== view || view.readOnly || view.allowedAppToolNames === void 0 || resolveMcpAppRequesterId(options.client) !== file.requesterId || loadGatewaySessionEntryReadOnly(sessionKey, { agentId: view.agentId }).entry?.sessionId !== file.sessionId || resolveLocalSessionWorkspaceRoot({ sessionKey, agentId: view.agentId }) !== file.rootDir) throw new Error("MCP App file authority expired"); if (mutation && view.authorizeAppInteraction) { const grant = view.authorizeAppInteraction(); if (grant !== true) { if (grant instanceof Promise) grant.catch(() => void 0); throw new Error("MCP App file requires current synchronous interaction authority"); } } }; try { await requireMcpAppInteraction(view); assertCurrent(); const result = await operation(file, assertCurrent); if (!mutation) { await requireMcpAppInteraction(view); assertCurrent(); } return result; } finally { read?.release(); } } async function readMcpAppHostFile(options, view, params) { return withHostFile(options, view, params.uri, async (file, assertCurrent) => { const representation = asOptionalRecord(params._meta?.["openai/resource"])?.representation; if (representation !== void 0 && representation !== "text" && representation !== "blob") throw new Error("Invalid resource representation"); const result = await readWorkspaceFile(file.rootDir, file.path, { assertCurrent }); assertCurrent(); if (!result || result === "too-large") throw new Error("File is missing, unsafe, or too large"); const writable = result.readOnly !== true; const content = representation === "text" ? { text: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(result.buffer) } : { blob: result.buffer.toString("base64") }; if (writable) writeAdmissions.add(view); return { contents: [{ uri: file.resourceUri, ...content, _meta: { "openai/resource": { writable, etag: etag(result.buffer) } } }] }; }); } async function writeMcpAppHostFile(options, view, params) { if (typeof params.uri !== "string") throw new Error("uri is required"); if (!view.hostFile || params.uri !== view.hostFile.resourceUri) throw new Error("Resource is not the file opened by this App"); await requireMcpAppInteraction(view); if (typeof params.text === "string" === (typeof params.blob === "string")) throw new Error("Provide exactly one of text or blob"); if (params.ifMatch !== void 0 && (typeof params.ifMatch !== "string" || !params.ifMatch)) throw new Error("ifMatch must be non-empty"); if (!writeAdmissions.has(view)) throw new Error("Read this resource with writable:true before writing"); let bytes; if (typeof params.text === "string") { if (Buffer.byteLength(params.text, "utf8") > 262144) return { outcome: "too-large", maxBytes: WORKSPACE_PREVIEW_MAX_BYTES }; bytes = Buffer.from(params.text, "utf8"); } else if (typeof params.blob === "string") { const blob = params.blob; if (blob.length > Math.ceil(262144 / 3) * 4) return { outcome: "too-large", maxBytes: WORKSPACE_PREVIEW_MAX_BYTES }; bytes = Buffer.from(blob, "base64"); if (bytes.toString("base64") !== blob) throw new Error("Invalid base64 resource content"); } else throw new Error("Provide exactly one of text or blob"); if (bytes.length > 262144) return { outcome: "too-large", maxBytes: WORKSPACE_PREVIEW_MAX_BYTES }; return withHostFile(options, view, params.uri, async (file, assertCurrent) => { const result = await updateWorkspaceFile(file.rootDir, file.path, bytes, typeof params.ifMatch === "string" ? params.ifMatch : void 0, assertCurrent); if (result.status === "unsafe") throw new Error("File could not be written safely"); return result.status === "conflict" ? { outcome: "conflict", etag: result.currentHash } : { outcome: "saved", etag: result.hash }; }, true); } async function subscribeMcpAppHostFile(options, view, uri, subscribe) { if (!subscribe) { const client = options.client; if (!client?.connId || view.hostFile?.resourceUri !== uri || view.requesterId !== resolveMcpAppRequesterId(client)) throw new Error("Resource subscription is not owned by this requester"); subscriptions.get(view)?.get(client.connId)?.(); return {}; } return withHostFile(options, view, uri, async (file, assertCurrent) => { const client = options.client; if (!client?.connId) throw new Error("Resource subscriptions require a live client connection"); const connId = client.connId; const sessionKey = view.runtime.sessionKey; if (!sessionKey) throw new Error("App session is unavailable"); const watchers = subscriptions.get(view) ?? /* @__PURE__ */ new Map(); watchers.get(connId)?.(); if (watchers.size >= 32) throw new Error("Resource subscription limit reached"); const result = await readWorkspaceFile(file.rootDir, file.path, { assertCurrent }); assertCurrent(); if (!result || result === "too-large") throw new Error("Resource cannot be subscribed"); let closed = false; let notifying = false; const isCurrent = () => !closed && !client.connectionSignal?.aborted && options.hasCurrentClientAuthority?.() !== false && getMcpAppViewLease(view.viewId, view.runtime) === view && !view.readOnly && view.allowedAppToolNames !== void 0 && resolveMcpAppRequesterId(client) === file.requesterId && options.context.getRuntimeConfig().mcp?.apps?.enabled === true && loadGatewaySessionEntryReadOnly(sessionKey, { agentId: view.agentId }).entry?.sessionId === file.sessionId && resolveLocalSessionWorkspaceRoot({ sessionKey, agentId: view.agentId }) === file.rootDir; const notify = async () => { if (notifying) return; notifying = true; try { if (!isCurrent()) { close("view, session, requester, or connection authority changed"); return; } await requireMcpAppInteraction(view); if (!isCurrent()) { close("authority changed while checking App interaction"); return; } options.context.broadcastToConnIds("mcp.app.resourceUpdated", { viewId: view.viewId, uri }, /* @__PURE__ */ new Set([connId])); } catch (error) { close(error); } finally { notifying = false; } }; const filePath = path.join(file.rootDir, file.path); let polling = false; const arm = () => watch(filePath, { persistent: false }, (event) => { if (event === "rename") { watcher.close(); rearm(); } notify(); }).on("error", close); const rearm = (curr) => { if (closed || curr && (!polling || !curr.isFile())) return; polling = false; unwatchFile(filePath, rearm); try { watcher = arm(); if (curr) notify(); } catch (error) { if (hasErrnoCode(error, "ENOENT")) { polling = true; watchFile(filePath, { persistent: false, interval: 250 }, rearm); stat(filePath, (statError, currentStats) => { if (!statError) rearm(currentStats); else if (!hasErrnoCode(statError, "ENOENT")) close(statError); }); } else close(error); } }; const close = (reason = "subscription ended") => { if (closed) return; closed = true; logDebug(`mcp-app: file subscription closed view=${view.viewId}: ${formatErrorMessage(reason)}`); releaseAuthority?.(); watcher.close(); unwatchFile(filePath, rearm); client.connectionSignal?.removeEventListener("abort", onDisconnect); if (watchers.get(connId) === close) watchers.delete(connId); view.disposeCallbacks?.delete(close); }; const onDisconnect = () => close("connection closed"); const releaseAuthority = retainGatewayDeviceRevocation(options.hasCurrentClientAuthority); let watcher; try { watcher = arm(); } catch (error) { releaseAuthority?.(); throw error; } client.connectionSignal?.addEventListener("abort", onDisconnect, { once: true }); watchers.set(connId, close); subscriptions.set(view, watchers); view.disposeCallbacks ??= /* @__PURE__ */ new Set(); view.disposeCallbacks.add(close); if (!isCurrent()) close("authority changed while installing the subscription"); return {}; }); } function canOpenMcpAppFiles(view) { const sessionKey = view.runtime.sessionKey; if (!sessionKey) return false; return Boolean(resolveLocalSessionWorkspaceRoot({ sessionKey, agentId: view.agentId })); } async function openMcpAppFile(options, view, filePath) { if (!path.isAbsolute(filePath)) throw new Error("File opening requires an absolute execution-host path"); if (!view.runtime.sessionKey) throw new Error("App session is unavailable"); await requireMcpAppInteraction(view); const file = await prepareMcpAppHostFile(options, { sessionKey: view.runtime.sessionKey, agentId: view.agentId, path: filePath }); await requireMcpAppInteraction(view); return { path: file.path, name: file.name }; } //#endregion export { subscribeMcpAppHostFile as a, readMcpAppHostFile as i, openMcpAppFile as n, writeMcpAppHostFile as o, prepareMcpAppHostFile as r, canOpenMcpAppFiles as t };