openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
229 lines (228 loc) • 11.3 kB
JavaScript
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 };