diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index ca14b904158..44b71ce8092 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -342,6 +342,9 @@ Facade-loaded bundled plugin public surfaces (`api.ts`, `runtime-api.ts`, `index.ts`, `setup-entry.ts`, and similar public entry files) prefer the active runtime config snapshot when OpenClaw is already running. If no runtime snapshot exists yet, they fall back to the resolved config file on disk. +Packaged bundled plugin facades should be loaded through the OpenClaw SDK +facade loaders; direct imports from `dist/extensions/...` bypass staged runtime +dependency mirrors that packaged installs use for plugin-owned dependencies. Provider plugins can expose a narrow plugin-local contract barrel when a helper is intentionally provider-specific and does not belong in a generic SDK diff --git a/src/plugin-sdk/facade-loader.test.ts b/src/plugin-sdk/facade-loader.test.ts index 2cb1f84e4b9..b0767367c44 100644 --- a/src/plugin-sdk/facade-loader.test.ts +++ b/src/plugin-sdk/facade-loader.test.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { clearBundledRuntimeDependencyNodePaths, @@ -8,6 +9,7 @@ import { import { shouldExpectNativeJitiForJavaScriptTestRuntime } from "../test-utils/jiti-runtime.js"; import { listImportedBundledPluginFacadeIds, + loadBundledPluginPublicSurfaceModule, loadBundledPluginPublicSurfaceModuleSync, resetFacadeLoaderStateForTest, setFacadeLoaderJitiFactoryForTest, @@ -24,6 +26,7 @@ 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"; +const STAGED_RUNTIME_DEP_NAME = "openclaw-facade-loader-runtime-dep"; type FacadeLoaderJitiFactory = NonNullable[0]>; function forceNodeRuntimeVersionsForTest(): () => void { @@ -82,74 +85,84 @@ function createCircularPluginDir(prefix: string): string { return rootDir; } -function createPackagedBundledPluginDirWithStagedRuntimeDep(prefix: string): { +function writeJsonFile(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function createPackagedBundledPluginDirWithStagedRuntimeDep(params: { + marker: string; + prefix: string; +}): { bundledPluginsDir: string; + env: NodeJS.ProcessEnv; + installRoot: string; + modulePath: string; packageRoot: string; pluginRoot: string; stageRoot: string; } { - const packageRoot = createTempDirSync(prefix); + const packageRoot = createTempDirSync(params.prefix); const pluginRoot = path.join(packageRoot, "dist", "extensions", "demo"); const stageRoot = path.join(packageRoot, "stage"); + const env = { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(packageRoot, "dist", "extensions"), + OPENCLAW_PLUGIN_STAGE_DIR: stageRoot, + }; fs.mkdirSync(pluginRoot, { recursive: true }); + + writeJsonFile(path.join(packageRoot, "package.json"), { + name: "openclaw", + version: "0.0.0", + type: "module", + }); + writeJsonFile(path.join(pluginRoot, "package.json"), { + name: "@openclaw/plugin-demo", + version: "0.0.0", + type: "module", + dependencies: { + [STAGED_RUNTIME_DEP_NAME]: "1.0.0", + }, + }); + const modulePath = path.join(pluginRoot, "api.js"); 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"), + modulePath, [ - 'import { marker as depMarker } from "facade-runtime-dep";', + `import { marker as depMarker } from ${JSON.stringify(STAGED_RUNTIME_DEP_NAME)};`, "export const marker = `facade:${depMarker}`;", + "export const moduleUrl = import.meta.url;", "", ].join("\n"), "utf8", ); const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { - env: { - ...process.env, - OPENCLAW_PLUGIN_STAGE_DIR: stageRoot, - }, + env, + }); + const depRoot = path.join(installRoot, "node_modules", STAGED_RUNTIME_DEP_NAME); + writeJsonFile(path.join(depRoot, "package.json"), { + name: STAGED_RUNTIME_DEP_NAME, + version: "1.0.0", + type: "module", + exports: "./index.js", }); - 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, - ), + path.join(depRoot, "index.js"), + `export const marker = ${JSON.stringify(params.marker)};\n`, "utf8", ); - fs.writeFileSync(path.join(depRoot, "index.js"), 'export const marker = "staged";\n', "utf8"); return { bundledPluginsDir: path.join(packageRoot, "dist", "extensions"), + env, + installRoot, + modulePath, packageRoot, pluginRoot, stageRoot, }; } - afterEach(() => { vi.restoreAllMocks(); resetFacadeLoaderStateForTest(); @@ -278,20 +291,52 @@ describe("plugin-sdk facade loader", () => { } }); - it("loads built bundled public surfaces through staged runtime deps", () => { - const fixture = createPackagedBundledPluginDirWithStagedRuntimeDep( - "openclaw-facade-loader-runtime-deps-", - ); + it("loads built bundled sync public surfaces through staged runtime deps", async () => { + const fixture = createPackagedBundledPluginDirWithStagedRuntimeDep({ + marker: "staged", + prefix: "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 }>({ + await expect(import(pathToFileURL(fixture.modulePath).href)).rejects.toMatchObject({ + code: "ERR_MODULE_NOT_FOUND", + }); + + const loaded = loadBundledPluginPublicSurfaceModuleSync<{ + marker: string; + moduleUrl: string; + }>({ dirName: "demo", artifactBasename: "api.js", }); expect(loaded.marker).toBe("facade:staged"); expect(fs.existsSync(path.join(fixture.pluginRoot, "node_modules"))).toBe(false); + expect(fs.realpathSync(fileURLToPath(loaded.moduleUrl))).toBe( + fs.realpathSync(path.join(fixture.installRoot, "dist", "extensions", "demo", "api.js")), + ); + }); + + it("loads built bundled async public surfaces through staged runtime deps", async () => { + const fixture = createPackagedBundledPluginDirWithStagedRuntimeDep({ + marker: "async-staged", + prefix: "openclaw-facade-loader-built-async-", + }); + + const loaded = await loadBundledPluginPublicSurfaceModule<{ + marker: string; + moduleUrl: string; + }>({ + dirName: "demo", + artifactBasename: "api.js", + env: fixture.env, + }); + + expect(loaded.marker).toBe("facade:async-staged"); + expect(fs.realpathSync(fileURLToPath(loaded.moduleUrl))).toBe( + fs.realpathSync(path.join(fixture.installRoot, "dist", "extensions", "demo", "api.js")), + ); }); it("breaks circular facade re-entry during module evaluation", () => {