UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

229 lines (228 loc) 11.3 kB
import { f as withPluginCache, i as createPluginCache } from "./plugin-cache-DGWspMEc.js"; import { r as loadPluginManifest } from "./manifest-ByRdkf9X.js"; import { o as getRuntimeConfigSnapshot } from "./runtime-snapshot-BaQikjTR.js"; import "./operator-scopes-Dw7Gu2cA.js"; import { n as capturePluginLifecycleAuthority } from "./registry-lifecycle-BozndFXl.js"; import { _ as getPluginRegistryForContext } from "./runtime-BL4wZfTq.js"; import { r as authorizeOperatorScopesForRequiredScope } from "./method-scopes-K6J_UQGL.js"; import { c as isControlUiPluginAllowed, s as CUSTOM_PLUGIN_UI_DISABLED_MESSAGE } from "./github-user-identity-DaoVKeEh.js"; import { a as controlUiPluginAssetRoot, i as controlUiPluginAssetPrefix } from "./route-match-BkI3BCZw.js"; import { n as authorizeControlUiPluginCookieRequest, r as authorizeControlUiReadRequestOrReply } from "./http-auth-utils-C3lb4QXY.js"; import { a as sendGatewayAuthFailure, c as sendMethodNotAllowed, v as respondNotFound } from "./http-common-BaZaosnr.js"; import { t as resolveSharedGatewaySessionGeneration } from "./ws-shared-generation-DZTjxt6J.js"; import { r as readPluginControlUiAssets } from "./control-ui-assets-CtXpBttK.js"; import { createHash } from "node:crypto"; //#region src/gateway/control-ui-plugin-assets.ts const MAX_CONTROL_UI_PLUGINS = 64; const MAX_CONTROL_UI_CATALOG_BYTES = 67108864; const MAX_CONTROL_UI_CATALOG_REVISIONS = 256; const browserCatalogs = /* @__PURE__ */ new WeakMap(); function catalogState(registry) { let state = browserCatalogs.get(registry); if (!state?.isCurrent()) { const isCurrent = capturePluginLifecycleAuthority(registry); if (!isCurrent?.()) throw new Error("plugin registry is no longer active"); state = { isCurrent, builds: /* @__PURE__ */ new Map(), revisions: /* @__PURE__ */ new Map(), diagnostics: /* @__PURE__ */ new Map(), activations: /* @__PURE__ */ new WeakMap(), initialized: false }; browserCatalogs.set(registry, state); } return state; } function browserOwners(registry) { return registry.plugins.filter((record) => record.enabled && record.status === "loaded" && record.controlUi).toSorted((left, right) => left.id.localeCompare(right.id)); } async function snapshotBrowserBuild(registry, record, declaration = record.controlUi) { const authority = capturePluginLifecycleAuthority(registry, record); const isCurrent = () => authority?.() === true && isControlUiPluginAllowed(record); if (!declaration || !record.rootDir || !isCurrent()) throw new Error("plugin is no longer active"); const { entryName, styles, assets, bytes } = await readPluginControlUiAssets(record.rootDir, declaration); const digest = createHash("sha256").update(JSON.stringify(declaration)); for (const [name, asset] of [...assets].toSorted(([left], [right]) => left.localeCompare(right))) digest.update(`${name}\0${asset.body.length}\0`).update(asset.body); const revision = digest.digest("hex"); const basePath = getRuntimeConfigSnapshot()?.gateway?.controlUi?.basePath; const prefix = `${controlUiPluginAssetPrefix(record.id, basePath)}${revision}/`; const assetUrl = (name) => `${prefix}${name.split("/").map(encodeURIComponent).join("/")}`; if (!isCurrent()) throw new Error("plugin was replaced while its browser assets loaded"); return { owner: record, isCurrent, assets, bytes, module: { pluginId: record.id, name: record.name, revision, entryUrl: assetUrl(entryName), styles: styles.map(assetUrl) } }; } async function refreshBrowserCatalog(registry, state, pluginId, reloadManifest = false) { if (!state.isCurrent()) throw new Error("plugin registry is no longer active"); const owners = browserOwners(registry); if (owners.length > MAX_CONTROL_UI_PLUGINS) throw new Error(`Native Control UI supports at most ${MAX_CONTROL_UI_PLUGINS} active plugins`); if (pluginId && !owners.some((record) => record.id === pluginId)) throw new Error("No active Control UI entrypoint for this plugin"); for (const record of owners) { if (!isControlUiPluginAllowed(record)) continue; if (state.initialized && pluginId && record.id !== pluginId) continue; try { let declaration = record.controlUi; if (reloadManifest) { const loaded = withPluginCache(createPluginCache(), () => loadPluginManifest(record.rootDir, true, record.rootDir)); if (!loaded.ok || loaded.manifest.id !== record.id || !loaded.manifest.controlUi) throw new Error("active plugin browser declaration is missing or invalid"); declaration = loaded.manifest.controlUi; } const build = await snapshotBrowserBuild(registry, record, declaration); for (const [key, retained] of state.revisions) if (!retained.isCurrent()) { state.revisions.delete(key); if (state.builds.get(retained.owner.id) === retained) state.builds.delete(retained.owner.id); } const revisionKey = `${record.id}/${build.module.revision}`; if (!state.revisions.has(revisionKey)) { if ([...state.revisions.values()].reduce((sum, retained) => sum + retained.bytes, build.bytes) > MAX_CONTROL_UI_CATALOG_BYTES || state.revisions.size >= MAX_CONTROL_UI_CATALOG_REVISIONS) { state.diagnostics.set(record.id, { pluginId: record.id, message: "Control UI revision cache is full. Restart the Gateway to load another browser build." }); continue; } } state.revisions.set(revisionKey, build); state.builds.set(record.id, build); state.diagnostics.delete(record.id); } catch { if (!state.isCurrent()) throw new Error("plugin registry was replaced while its browser assets loaded"); state.diagnostics.set(record.id, { pluginId: record.id, message: "Control UI assets could not be loaded. Build the plugin and reload its UI." }); } } state.initialized = true; } function projectBrowserCatalog(registry, state) { if (state && !state.isCurrent()) throw new Error("plugin registry is no longer active"); const active = registry ? browserOwners(registry) : []; const owners = new Set(active.filter(isControlUiPluginAllowed).map((record) => record.id)); const plugins = [...state?.builds.values() ?? []].filter((build) => owners.has(build.owner.id) && build.isCurrent()).map((build) => build.module).toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)); const diagnostics = [...[...state?.diagnostics.values() ?? []].filter((diagnostic) => owners.has(diagnostic.pluginId)), ...active.filter((record) => !isControlUiPluginAllowed(record)).map((record) => ({ pluginId: record.id, code: "custom-plugin-ui-disabled", message: CUSTOM_PLUGIN_UI_DISABLED_MESSAGE }))].toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)); return { revision: createHash("sha256").update(JSON.stringify({ plugins, diagnostics })).digest("hex"), plugins, diagnostics }; } async function loadControlUiPluginCatalog(pluginId, reloadManifest = false) { const registry = getPluginRegistryForContext(); if (!registry) { if (pluginId) throw new Error("No active Control UI entrypoint for this plugin"); return projectBrowserCatalog(null); } const state = catalogState(registry); const operation = (state.pending ?? Promise.resolve()).then(async () => { if (reloadManifest || !state.initialized) await refreshBrowserCatalog(registry, state, pluginId, reloadManifest); return projectBrowserCatalog(registry, state); }); const pending = operation.then(() => void 0, () => void 0); state.pending = pending; try { return await operation; } finally { if (state.pending === pending) state.pending = void 0; } } /** Explicit UI-only refresh; never imports or replaces backend plugin code. */ function reloadControlUiPluginCatalog(pluginId) { return loadControlUiPluginCatalog(pluginId, true); } function listControlUiPluginCatalog() { return loadControlUiPluginCatalog(); } /** A receipt is an observation by one live browser connection, never activation authority. */ function reportControlUiPluginActivation(client, report) { const registry = getPluginRegistryForContext(); const state = registry && browserCatalogs.get(registry); const build = state && state.builds.get(report.pluginId); if (!state || !build?.isCurrent() || build.module.revision !== report.revision) return false; const activations = state.activations.get(client) ?? /* @__PURE__ */ new Map(); activations.set(report.pluginId, { ...report }); state.activations.set(client, activations); return true; } function listControlUiPluginActivations(client, pluginId) { const registry = getPluginRegistryForContext(); const state = registry && browserCatalogs.get(registry); if (!state?.isCurrent()) return []; return [...state.activations.get(client)?.values() ?? []].filter((report) => { const build = state.builds.get(report.pluginId); return (!pluginId || pluginId === report.pluginId) && build?.isCurrent() && build.module.revision === report.revision; }).toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)); } /** Serves only snapshot bytes after scoped plugin-cookie or explicit read authentication. */ async function handleControlUiPluginAssetRequest(req, res, params) { const pathname = new URL(req.url ?? "/", "http://localhost").pathname; const assetRoot = controlUiPluginAssetRoot(params.basePath); if (!pathname.startsWith(assetRoot)) return false; if (req.method !== "GET" && req.method !== "HEAD") { sendMethodNotAllowed(res, "GET, HEAD"); return true; } let segments; try { segments = pathname.slice(assetRoot.length).split("/").map(decodeURIComponent); } catch { respondNotFound(res); return true; } const [pluginId, revision, ...fileParts] = segments; if (!pluginId || !/^[a-f0-9]{64}$/u.test(revision ?? "") || fileParts.length === 0 || fileParts.some((part) => !part || part === "." || part === ".." || /[\\/\0]/u.test(part))) { respondNotFound(res); return true; } const cookieAuth = authorizeControlUiPluginCookieRequest(req, { requestPath: pathname, authGeneration: resolveSharedGatewaySessionGeneration(params.auth, params.trustedProxies) }); if (cookieAuth) { if (!cookieAuth.requestAuth.controlUiPluginGrants?.find((candidate) => candidate.pluginId === pluginId && authorizeOperatorScopesForRequiredScope("operator.read", candidate.scopes).allowed)) { sendGatewayAuthFailure(res, { ok: false, reason: "unauthorized" }); return true; } } else if (!await authorizeControlUiReadRequestOrReply({ req, res, ...params })) return true; const registry = getPluginRegistryForContext(); const build = (registry && browserCatalogs.get(registry))?.revisions.get(`${pluginId}/${revision}`); const asset = build && build.module.revision === revision && build.isCurrent() ? build.assets.get(fileParts.join("/")) : void 0; if (!asset) { respondNotFound(res); return true; } res.statusCode = 200; res.setHeader("Content-Type", asset.contentType); res.setHeader("Content-Length", asset.body.length); res.setHeader("Cache-Control", "private, no-cache"); res.setHeader("X-Content-Type-Options", "nosniff"); res.end(req.method === "HEAD" ? void 0 : asset.body); return true; } //#endregion export { reportControlUiPluginActivation as a, reloadControlUiPluginCatalog as i, listControlUiPluginActivations as n, listControlUiPluginCatalog as r, handleControlUiPluginAssetRequest as t };