From 09a2ffc47a41e0dcb67e38b3c2d5986da5b04664 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 03:34:38 +0100 Subject: [PATCH] fix: prepare public artifact runtime deps --- src/plugin-sdk/facade-loader.ts | 45 +------- .../bundled-public-surface-runtime-root.ts | 58 ++++++++++ src/plugins/public-surface-loader.test.ts | 102 ++++++++++++++++++ src/plugins/public-surface-loader.ts | 19 +++- 4 files changed, 177 insertions(+), 47 deletions(-) create mode 100644 src/plugins/bundled-public-surface-runtime-root.ts diff --git a/src/plugin-sdk/facade-loader.ts b/src/plugin-sdk/facade-loader.ts index faf46c46ace..2de9ee03865 100644 --- a/src/plugin-sdk/facade-loader.ts +++ b/src/plugin-sdk/facade-loader.ts @@ -4,10 +4,7 @@ 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 { prepareBuiltBundledPluginPublicSurfaceLocation } from "../plugins/bundled-public-surface-runtime-root.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache, @@ -174,30 +171,6 @@ 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?: { @@ -208,23 +181,11 @@ function prepareFacadeLocationForBundledRuntimeDeps(params: { if (!params.runtimeDeps) { return params.location; } - const pluginRoot = resolveBuiltBundledPluginRoot({ - modulePath: params.location.modulePath, + return prepareBuiltBundledPluginPublicSurfaceLocation({ + location: params.location, 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: { diff --git a/src/plugins/bundled-public-surface-runtime-root.ts b/src/plugins/bundled-public-surface-runtime-root.ts new file mode 100644 index 00000000000..d539825011f --- /dev/null +++ b/src/plugins/bundled-public-surface-runtime-root.ts @@ -0,0 +1,58 @@ +import path from "node:path"; +import { + isBuiltBundledPluginRuntimeRoot, + prepareBundledPluginRuntimeRoot, +} from "./bundled-runtime-root.js"; + +export type BundledPublicSurfaceLocation = { + modulePath: string; + boundaryRoot: string; +}; + +export function resolveBuiltBundledPluginRootFromModulePath(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; + } +} + +export function prepareBuiltBundledPluginPublicSurfaceLocation(params: { + location: BundledPublicSurfaceLocation; + pluginId: string; + env?: NodeJS.ProcessEnv; +}): BundledPublicSurfaceLocation { + const pluginRoot = resolveBuiltBundledPluginRootFromModulePath({ + modulePath: params.location.modulePath, + pluginId: params.pluginId, + }); + if (!pluginRoot) { + return params.location; + } + const prepared = prepareBundledPluginRuntimeRoot({ + pluginId: params.pluginId, + pluginRoot, + modulePath: params.location.modulePath, + ...(params.env ? { env: params.env } : {}), + }); + return { + modulePath: prepared.modulePath, + boundaryRoot: prepared.pluginRoot, + }; +} diff --git a/src/plugins/public-surface-loader.test.ts b/src/plugins/public-surface-loader.test.ts index d888e9b0352..4e9263ae94f 100644 --- a/src/plugins/public-surface-loader.test.ts +++ b/src/plugins/public-surface-loader.test.ts @@ -3,9 +3,14 @@ import os from "node:os"; import path from "node:path"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + clearBundledRuntimeDependencyNodePaths, + resolveBundledRuntimeDependencyInstallRoot, +} from "./bundled-runtime-deps.js"; const tempDirs: string[] = []; const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; +const originalPluginStageDir = process.env.OPENCLAW_PLUGIN_STAGE_DIR; function createTempDir(): string { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-public-surface-loader-")); @@ -13,6 +18,77 @@ function createTempDir(): string { return tempDir; } +function createPackagedPublicArtifactWithStagedRuntimeDep(): { + bundledPluginsDir: string; + pluginRoot: string; + stageRoot: string; +} { + const packageRoot = createTempDir(); + 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: { + "public-artifact-runtime-dep": "1.0.0", + }, + }, + null, + 2, + ), + "utf8", + ); + fs.writeFileSync( + path.join(pluginRoot, "provider-policy-api.js"), + [ + 'import { marker as depMarker } from "public-artifact-runtime-dep";', + "export const marker = `artifact:${depMarker}`;", + "", + ].join("\n"), + "utf8", + ); + + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { + env: { + ...process.env, + OPENCLAW_PLUGIN_STAGE_DIR: stageRoot, + }, + }); + const depRoot = path.join(installRoot, "node_modules", "public-artifact-runtime-dep"); + fs.mkdirSync(depRoot, { recursive: true }); + fs.writeFileSync( + path.join(depRoot, "package.json"), + JSON.stringify( + { + name: "public-artifact-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"), + pluginRoot, + stageRoot, + }; +} + afterEach(() => { for (const tempDir of tempDirs.splice(0)) { fs.rmSync(tempDir, { recursive: true, force: true }); @@ -20,11 +96,18 @@ afterEach(() => { vi.restoreAllMocks(); vi.resetModules(); vi.doUnmock("jiti"); + vi.doUnmock("node:module"); + clearBundledRuntimeDependencyNodePaths(); if (originalBundledPluginsDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; } else { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir; } + if (originalPluginStageDir === undefined) { + delete process.env.OPENCLAW_PLUGIN_STAGE_DIR; + } else { + process.env.OPENCLAW_PLUGIN_STAGE_DIR = originalPluginStageDir; + } }); describe("bundled plugin public surface loader", () => { @@ -140,6 +223,25 @@ describe("bundled plugin public surface loader", () => { expect(createJiti).toHaveBeenCalledTimes(1); }); + it("loads built public artifacts through staged runtime deps", async () => { + const publicSurfaceLoader = await importFreshModule< + typeof import("./public-surface-loader.js") + >(import.meta.url, "./public-surface-loader.js?scope=runtime-deps"); + const fixture = createPackagedPublicArtifactWithStagedRuntimeDep(); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = fixture.bundledPluginsDir; + process.env.OPENCLAW_PLUGIN_STAGE_DIR = fixture.stageRoot; + + const loaded = publicSurfaceLoader.loadBundledPluginPublicArtifactModuleSync<{ + marker: string; + }>({ + dirName: "demo", + artifactBasename: "provider-policy-api.js", + }); + + expect(loaded.marker).toBe("artifact:staged"); + expect(fs.existsSync(path.join(fixture.pluginRoot, "node_modules"))).toBe(false); + }); + it("rejects public artifacts that change after boundary validation", async () => { const createJiti = vi.fn(() => vi.fn(() => ({ marker: "should-not-load" }))); vi.doMock("jiti", () => ({ diff --git a/src/plugins/public-surface-loader.ts b/src/plugins/public-surface-loader.ts index b8ff99dda20..26b29db5b95 100644 --- a/src/plugins/public-surface-loader.ts +++ b/src/plugins/public-surface-loader.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { sameFileIdentity } from "../infra/file-identity.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; +import { prepareBuiltBundledPluginPublicSurfaceLocation } from "./bundled-public-surface-runtime-root.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; import { resolveBundledPluginPublicSurfacePath } from "./public-surface-runtime.js"; import { @@ -150,18 +151,24 @@ export function loadBundledPluginPublicArtifactModuleSync(para `Unable to resolve bundled plugin public surface ${params.dirName}/${params.artifactBasename}`, ); } - const cached = loadedPublicSurfaceModules.get(location.modulePath); + const preparedLocation = prepareBuiltBundledPluginPublicSurfaceLocation({ + location, + pluginId: params.dirName, + }); + const cached = + loadedPublicSurfaceModules.get(location.modulePath) ?? + loadedPublicSurfaceModules.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 === OPENCLAW_PACKAGE_ROOT + preparedLocation.boundaryRoot === OPENCLAW_PACKAGE_ROOT ? "OpenClaw package root" - : "bundled plugin directory", + : "plugin root", rejectHardlinks: true, }); if (!opened.ok) { @@ -183,6 +190,7 @@ export function loadBundledPluginPublicArtifactModuleSync(para const sentinel = {} as T; loadedPublicSurfaceModules.set(location.modulePath, sentinel); + loadedPublicSurfaceModules.set(preparedLocation.modulePath, sentinel); loadedPublicSurfaceModules.set(validatedPath, sentinel); try { const loaded = loadPublicSurfaceModule(validatedPath) as T; @@ -190,6 +198,7 @@ export function loadBundledPluginPublicArtifactModuleSync(para return sentinel; } catch (error) { loadedPublicSurfaceModules.delete(location.modulePath); + loadedPublicSurfaceModules.delete(preparedLocation.modulePath); loadedPublicSurfaceModules.delete(validatedPath); throw error; }