From 88068b9649f7460c443235045069dd0fba9d7383 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 03:25:01 +0100 Subject: [PATCH] fix: prepare bundled facade runtime deps --- CHANGELOG.md | 1 + src/plugin-sdk/facade-loader.test.ts | 95 ++++++++++++++++++++++ src/plugin-sdk/facade-loader.ts | 115 ++++++++++++++++++++++----- src/plugin-sdk/facade-runtime.ts | 8 ++ 4 files changed, 200 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88066de979c..e143856f759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Memory/Ollama: resolve `memorySearch.provider` custom provider ids through their configured `models.providers..api` owner, so multi-GPU Ollama setups can dedicate embeddings to providers such as `ollama-5080` without losing the Ollama adapter or local auth semantics. Fixes #73150. Thanks @oneandrewwang. - CLI/memory: skip eager context-window warmup for `openclaw memory` commands so memory search does not race unrelated model metadata discovery. Fixes #73123. Thanks @oalansilva and @neeravmakwana. - CLI/Telegram: route Telegram `message send` and poll actions through the running Gateway when available, so packaged installs use the staged `grammy` runtime deps and CLI sends return instead of hanging after the Telegram channel is active. Fixes #73140. Thanks @oalansilva. +- Plugins/runtime deps: prepare staged bundled plugin dependencies before loading packaged public surfaces, so OpenClaw's Telegram runtime/test facade loads resolve `grammy` from the managed runtime-deps stage without copying dependencies into the global package root. Refs #73140. Thanks @oalansilva. - Cron/providers: preflight local Ollama and OpenAI-compatible provider endpoints before isolated cron agent turns, record unreachable local providers as skipped runs, and cache dead-endpoint probes so many jobs do not hammer the same stopped local server. Fixes #58584. Thanks @jpeghead. - Gateway/config: let config reload continue in degraded mode when invalidity is scoped to plugin entries, so incompatible plugin configs can be skipped and the Gateway restart can still pick up the rest of the config after rollbacks. Fixes #73131. Thanks @Adam-Researchh. - Doctor/channels: suppress disabled bundled-plugin blocker warnings when a trusted external plugin owns the configured channel, so Lark/Feishu installs no longer get Feishu repair noise after switching to `openclaw-lark`. Fixes #56794. Thanks @wuji-tech-dev. diff --git a/src/plugin-sdk/facade-loader.test.ts b/src/plugin-sdk/facade-loader.test.ts index cc9fab4c20b..2cb1f84e4b9 100644 --- a/src/plugin-sdk/facade-loader.test.ts +++ b/src/plugin-sdk/facade-loader.test.ts @@ -1,6 +1,10 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + clearBundledRuntimeDependencyNodePaths, + resolveBundledRuntimeDependencyInstallRoot, +} from "../plugins/bundled-runtime-deps.js"; import { shouldExpectNativeJitiForJavaScriptTestRuntime } from "../test-utils/jiti-runtime.js"; import { listImportedBundledPluginFacadeIds, @@ -18,6 +22,7 @@ import { const { createTempDirSync } = createPluginSdkTestHarness(); const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; const originalDisableBundledPlugins = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; +const originalPluginStageDir = process.env.OPENCLAW_PLUGIN_STAGE_DIR; const FACADE_LOADER_GLOBAL = "__openclawTestLoadBundledPluginPublicSurfaceModuleSync"; type FacadeLoaderJitiFactory = NonNullable[0]>; @@ -77,10 +82,79 @@ function createCircularPluginDir(prefix: string): string { return rootDir; } +function createPackagedBundledPluginDirWithStagedRuntimeDep(prefix: string): { + bundledPluginsDir: string; + packageRoot: string; + pluginRoot: string; + stageRoot: string; +} { + const packageRoot = createTempDirSync(prefix); + const pluginRoot = path.join(packageRoot, "dist", "extensions", "demo"); + const stageRoot = path.join(packageRoot, "stage"); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "0.0.0", type: "module" }, null, 2), + "utf8", + ); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify( + { + name: "@openclaw/plugin-demo", + version: "0.0.0", + type: "module", + dependencies: { + "facade-runtime-dep": "1.0.0", + }, + }, + null, + 2, + ), + "utf8", + ); + fs.writeFileSync( + path.join(pluginRoot, "api.js"), + [ + 'import { marker as depMarker } from "facade-runtime-dep";', + "export const marker = `facade:${depMarker}`;", + "", + ].join("\n"), + "utf8", + ); + + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { + env: { + ...process.env, + OPENCLAW_PLUGIN_STAGE_DIR: stageRoot, + }, + }); + const depRoot = path.join(installRoot, "node_modules", "facade-runtime-dep"); + fs.mkdirSync(depRoot, { recursive: true }); + fs.writeFileSync( + path.join(depRoot, "package.json"), + JSON.stringify( + { name: "facade-runtime-dep", version: "1.0.0", type: "module", exports: "./index.js" }, + null, + 2, + ), + "utf8", + ); + fs.writeFileSync(path.join(depRoot, "index.js"), 'export const marker = "staged";\n', "utf8"); + + return { + bundledPluginsDir: path.join(packageRoot, "dist", "extensions"), + packageRoot, + pluginRoot, + stageRoot, + }; +} + afterEach(() => { vi.restoreAllMocks(); resetFacadeLoaderStateForTest(); setFacadeLoaderJitiFactoryForTest(undefined); + clearBundledRuntimeDependencyNodePaths(); delete (globalThis as typeof globalThis & Record)[FACADE_LOADER_GLOBAL]; if (originalBundledPluginsDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; @@ -92,6 +166,11 @@ afterEach(() => { } else { process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = originalDisableBundledPlugins; } + if (originalPluginStageDir === undefined) { + delete process.env.OPENCLAW_PLUGIN_STAGE_DIR; + } else { + process.env.OPENCLAW_PLUGIN_STAGE_DIR = originalPluginStageDir; + } }); describe("plugin-sdk facade loader", () => { @@ -199,6 +278,22 @@ describe("plugin-sdk facade loader", () => { } }); + it("loads built bundled public surfaces through staged runtime deps", () => { + const fixture = createPackagedBundledPluginDirWithStagedRuntimeDep( + "openclaw-facade-loader-runtime-deps-", + ); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = fixture.bundledPluginsDir; + process.env.OPENCLAW_PLUGIN_STAGE_DIR = fixture.stageRoot; + + const loaded = loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ + dirName: "demo", + artifactBasename: "api.js", + }); + + expect(loaded.marker).toBe("facade:staged"); + expect(fs.existsSync(path.join(fixture.pluginRoot, "node_modules"))).toBe(false); + }); + it("breaks circular facade re-entry during module evaluation", () => { const dir = createCircularPluginDir("openclaw-facade-loader-circular-"); process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; diff --git a/src/plugin-sdk/facade-loader.ts b/src/plugin-sdk/facade-loader.ts index ae7065e5121..faf46c46ace 100644 --- a/src/plugin-sdk/facade-loader.ts +++ b/src/plugin-sdk/facade-loader.ts @@ -4,6 +4,10 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js"; +import { + isBuiltBundledPluginRuntimeRoot, + prepareBundledPluginRuntimeRoot, +} from "../plugins/bundled-runtime-root.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache, @@ -170,46 +174,106 @@ export type FacadeModuleLocation = { boundaryRoot: string; }; +function resolveBuiltBundledPluginRoot(params: { + modulePath: string; + pluginId: string; +}): string | null { + const resolvedModulePath = path.resolve(params.modulePath); + let currentDir = path.dirname(resolvedModulePath); + while (true) { + if ( + path.basename(currentDir) === params.pluginId && + isBuiltBundledPluginRuntimeRoot(currentDir) + ) { + const relativePath = path.relative(currentDir, resolvedModulePath); + if (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) { + return currentDir; + } + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +function prepareFacadeLocationForBundledRuntimeDeps(params: { + location: FacadeModuleLocation; + runtimeDeps?: { + pluginId: string; + env?: NodeJS.ProcessEnv; + }; +}): FacadeModuleLocation { + if (!params.runtimeDeps) { + return params.location; + } + const pluginRoot = resolveBuiltBundledPluginRoot({ + modulePath: params.location.modulePath, + pluginId: params.runtimeDeps.pluginId, + }); + if (!pluginRoot) { + return params.location; + } + const prepared = prepareBundledPluginRuntimeRoot({ + pluginId: params.runtimeDeps.pluginId, + pluginRoot, + modulePath: params.location.modulePath, + ...(params.runtimeDeps.env ? { env: params.runtimeDeps.env } : {}), + }); + return { + modulePath: prepared.modulePath, + boundaryRoot: prepared.pluginRoot, + }; +} + export function loadFacadeModuleAtLocationSync(params: { location: FacadeModuleLocation; trackedPluginId: string | (() => string); + runtimeDeps?: { + pluginId: string; + env?: NodeJS.ProcessEnv; + }; loadModule?: (modulePath: string) => T; }): T { - const cached = loadedFacadeModules.get(params.location.modulePath); + const location = prepareFacadeLocationForBundledRuntimeDeps({ + location: params.location, + ...(params.runtimeDeps ? { runtimeDeps: params.runtimeDeps } : {}), + }); + const cached = loadedFacadeModules.get(location.modulePath); if (cached) { return cached as T; } const opened = openBoundaryFileSync({ - absolutePath: params.location.modulePath, - rootPath: params.location.boundaryRoot, + absolutePath: location.modulePath, + rootPath: location.boundaryRoot, boundaryLabel: - params.location.boundaryRoot === getOpenClawPackageRoot() + location.boundaryRoot === getOpenClawPackageRoot() ? "OpenClaw package root" : (() => { const bundledDir = resolveBundledPluginsDir(); - return bundledDir && - path.resolve(params.location.boundaryRoot) === path.resolve(bundledDir) + return bundledDir && path.resolve(location.boundaryRoot) === path.resolve(bundledDir) ? "bundled plugin directory" : "plugin root"; })(), rejectHardlinks: false, }); if (!opened.ok) { - throw new Error(`Unable to open bundled plugin public surface ${params.location.modulePath}`, { + throw new Error(`Unable to open bundled plugin public surface ${location.modulePath}`, { cause: opened.error, }); } fs.closeSync(opened.fd); const sentinel = {} as T; - loadedFacadeModules.set(params.location.modulePath, sentinel); + loadedFacadeModules.set(location.modulePath, sentinel); let loaded: T; try { loaded = - params.loadModule?.(params.location.modulePath) ?? - (getJiti(params.location.modulePath)(params.location.modulePath) as T); + params.loadModule?.(location.modulePath) ?? + (getJiti(location.modulePath)(location.modulePath) as T); Object.assign(sentinel, loaded); loadedFacadePluginIds.add( typeof params.trackedPluginId === "function" @@ -217,7 +281,7 @@ export function loadFacadeModuleAtLocationSync(params: { : params.trackedPluginId, ); } catch (err) { - loadedFacadeModules.delete(params.location.modulePath); + loadedFacadeModules.delete(location.modulePath); throw err; } @@ -240,6 +304,10 @@ export function loadBundledPluginPublicSurfaceModuleSync(param return loadFacadeModuleAtLocationSync({ location, trackedPluginId: params.trackedPluginId ?? params.dirName, + runtimeDeps: { + pluginId: params.dirName, + ...(params.env ? { env: params.env } : {}), + }, }); } @@ -255,28 +323,37 @@ export async function loadBundledPluginPublicSurfaceModule(par `Unable to resolve bundled plugin public surface ${params.dirName}/${params.artifactBasename}`, ); } - const cached = loadedFacadeModules.get(location.modulePath); + const preparedLocation = prepareFacadeLocationForBundledRuntimeDeps({ + location, + runtimeDeps: { + pluginId: params.dirName, + ...(params.env ? { env: params.env } : {}), + }, + }); + const cached = loadedFacadeModules.get(preparedLocation.modulePath); if (cached) { return cached as T; } const opened = openBoundaryFileSync({ - absolutePath: location.modulePath, - rootPath: location.boundaryRoot, + absolutePath: preparedLocation.modulePath, + rootPath: preparedLocation.boundaryRoot, boundaryLabel: - location.boundaryRoot === getOpenClawPackageRoot() ? "OpenClaw package root" : "plugin root", + preparedLocation.boundaryRoot === getOpenClawPackageRoot() + ? "OpenClaw package root" + : "plugin root", rejectHardlinks: false, }); if (!opened.ok) { - throw new Error(`Unable to open bundled plugin public surface ${location.modulePath}`, { + throw new Error(`Unable to open bundled plugin public surface ${preparedLocation.modulePath}`, { cause: opened.error, }); } fs.closeSync(opened.fd); try { - const loaded = (await import(pathToFileURL(location.modulePath).href)) as T; - loadedFacadeModules.set(location.modulePath, loaded); + const loaded = (await import(pathToFileURL(preparedLocation.modulePath).href)) as T; + loadedFacadeModules.set(preparedLocation.modulePath, loaded); loadedFacadePluginIds.add( typeof params.trackedPluginId === "function" ? params.trackedPluginId() @@ -285,7 +362,7 @@ export async function loadBundledPluginPublicSurfaceModule(par return loaded; } catch { return loadFacadeModuleAtLocationSync({ - location, + location: preparedLocation, trackedPluginId: params.trackedPluginId ?? params.dirName, }); } diff --git a/src/plugin-sdk/facade-runtime.ts b/src/plugin-sdk/facade-runtime.ts index bfe5f839b9b..ae30b0764cd 100644 --- a/src/plugin-sdk/facade-runtime.ts +++ b/src/plugin-sdk/facade-runtime.ts @@ -172,6 +172,10 @@ function loadFacadeActivationCheckRuntime(): FacadeActivationCheckRuntimeModule function loadFacadeModuleAtLocationSync(params: { location: FacadeModuleLocation; trackedPluginId: string | (() => string); + runtimeDeps?: { + pluginId: string; + env?: NodeJS.ProcessEnv; + }; loadModule?: (modulePath: string) => T; }): T { return loadFacadeModuleAtLocationSyncShared(params); @@ -207,6 +211,10 @@ export function loadBundledPluginPublicSurfaceModuleSync( return loadFacadeModuleAtLocationSync({ location, trackedPluginId, + runtimeDeps: { + pluginId: params.dirName, + ...(params.env ? { env: params.env } : {}), + }, }); }