mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 21:40:24 +00:00
fix: resolve global bundled plugin facade fallback (#61297) (thanks @openperf)
* fix(gateway): resolve globally-installed bundled plugins in facade-runtime * fix: resolve global bundled plugin facade fallback (#61297) (thanks @openperf) --------- Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
@@ -3,6 +3,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
|
||||
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
|
||||
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
|
||||
import {
|
||||
canLoadActivatedBundledPluginPublicSurface,
|
||||
listImportedBundledPluginFacadeIds,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const FACADE_RUNTIME_GLOBAL = "__openclawTestLoadBundledPluginPublicSurfaceModuleSync";
|
||||
|
||||
function createBundledPluginDir(prefix: string, marker: string): string {
|
||||
@@ -75,6 +78,8 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearRuntimeConfigSnapshot();
|
||||
resetFacadeRuntimeStateForTest();
|
||||
clearPluginDiscoveryCache();
|
||||
clearPluginManifestRegistryCache();
|
||||
vi.doUnmock("../plugins/manifest-registry.js");
|
||||
delete (globalThis as typeof globalThis & Record<string, unknown>)[FACADE_RUNTIME_GLOBAL];
|
||||
if (originalBundledPluginsDir === undefined) {
|
||||
@@ -82,6 +87,11 @@ afterEach(() => {
|
||||
} else {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir;
|
||||
}
|
||||
if (originalStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = originalStateDir;
|
||||
}
|
||||
for (const dir of tempDirs.splice(0, tempDirs.length)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -256,6 +266,141 @@ describe("plugin-sdk facade runtime", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves a globally-installed plugin whose rootDir basename matches the dirName", () => {
|
||||
// Simulate a global-only installation: the bundled plugins directory does
|
||||
// NOT contain the target plugin, but a global extensions directory does.
|
||||
// The facade module location resolver should fall through to the registry-
|
||||
// based lookup and find the module inside the global rootDir.
|
||||
const emptyBundled = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-facade-empty-bundled-"));
|
||||
tempDirs.push(emptyBundled);
|
||||
|
||||
// Create a state dir whose "extensions" sub-directory acts as the global
|
||||
// plugin root (resolveConfigDir(env) + "/extensions").
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-facade-state-"));
|
||||
tempDirs.push(stateDir);
|
||||
const lineDir = path.join(stateDir, "extensions", "line");
|
||||
fs.mkdirSync(lineDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(lineDir, "runtime-api.js"),
|
||||
'export const marker = "global-line";\n',
|
||||
"utf8",
|
||||
);
|
||||
// Discovery reads package.json (not openclaw.plugin.json) and looks for
|
||||
// the "openclaw" key to resolve extension entries.
|
||||
fs.writeFileSync(
|
||||
path.join(lineDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@openclaw/line",
|
||||
version: "0.0.0",
|
||||
openclaw: {
|
||||
extensions: ["./runtime-api.js"],
|
||||
channel: { id: "line" },
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
// The plugin manifest (openclaw.plugin.json) is loaded separately by the
|
||||
// registry builder to populate id, channels, etc.
|
||||
fs.writeFileSync(
|
||||
path.join(lineDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "line",
|
||||
channels: ["line"],
|
||||
configSchema: { type: "object", additionalProperties: false, properties: {} },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = emptyBundled;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
clearPluginDiscoveryCache();
|
||||
clearPluginManifestRegistryCache();
|
||||
resetFacadeRuntimeStateForTest();
|
||||
|
||||
setRuntimeConfigSnapshot({
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The plugin should be resolvable via the registry fallback even though
|
||||
// the bundled plugins directory is empty.
|
||||
expect(
|
||||
canLoadActivatedBundledPluginPublicSurface({
|
||||
dirName: "line",
|
||||
artifactBasename: "runtime-api.js",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves a globally-installed plugin with an encoded scoped rootDir basename", () => {
|
||||
// When a scoped package like @openclaw/line is installed globally, its
|
||||
// directory name is encoded (e.g. "@openclaw+line"). The rootDir basename
|
||||
// no longer matches the facade dirName "line", so the resolver must fall
|
||||
// back to matching by plugin.id or plugin.channels.
|
||||
const emptyBundled = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-facade-empty-bundled-"));
|
||||
tempDirs.push(emptyBundled);
|
||||
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-facade-state-"));
|
||||
tempDirs.push(stateDir);
|
||||
// Use the encoded scoped package directory name.
|
||||
const encodedDir = path.join(stateDir, "extensions", "@openclaw+line");
|
||||
fs.mkdirSync(encodedDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(encodedDir, "runtime-api.js"),
|
||||
'export const marker = "encoded-global-line";\n',
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(encodedDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@openclaw/line",
|
||||
version: "0.0.0",
|
||||
openclaw: {
|
||||
extensions: ["./runtime-api.js"],
|
||||
channel: { id: "line" },
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(encodedDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "line",
|
||||
channels: ["line"],
|
||||
configSchema: { type: "object", additionalProperties: false, properties: {} },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = emptyBundled;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
clearPluginDiscoveryCache();
|
||||
clearPluginManifestRegistryCache();
|
||||
resetFacadeRuntimeStateForTest();
|
||||
|
||||
setRuntimeConfigSnapshot({
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The plugin.id fallback must resolve the plugin even though
|
||||
// path.basename(rootDir) is "@openclaw+line", not "line".
|
||||
expect(
|
||||
canLoadActivatedBundledPluginPublicSurface({
|
||||
dirName: "line",
|
||||
artifactBasename: "runtime-api.js",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps shared runtime-core facades available without plugin activation", () => {
|
||||
setRuntimeConfigSnapshot({});
|
||||
|
||||
|
||||
@@ -69,6 +69,43 @@ function resolveSourceFirstPublicSurfacePath(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveRegistryPluginModuleLocation(params: {
|
||||
dirName: string;
|
||||
artifactBasename: string;
|
||||
}): { modulePath: string; boundaryRoot: string } | null {
|
||||
const { config } = getFacadeBoundaryResolvedConfig();
|
||||
const registry = loadPluginManifestRegistry({ config, cache: true }).plugins;
|
||||
// Use tiered matching so exact basename/id matches are always preferred over
|
||||
// loose channel matches. A plugin that merely declares `channels: ["line"]`
|
||||
// must never shadow the actual LINE plugin whose id is `"line"`. Within each
|
||||
// tier we iterate all matching records so that a stale first match (e.g. a
|
||||
// bundled root that lost its artifact) does not shadow a later valid record.
|
||||
const tiers: Array<(plugin: (typeof registry)[number]) => boolean> = [
|
||||
(plugin) => plugin.id === params.dirName,
|
||||
(plugin) => path.basename(plugin.rootDir) === params.dirName,
|
||||
(plugin) => plugin.channels.includes(params.dirName),
|
||||
];
|
||||
const artifactBasename = params.artifactBasename.replace(/^\.\//u, "");
|
||||
const sourceBaseName = artifactBasename.replace(/\.js$/u, "");
|
||||
for (const matchFn of tiers) {
|
||||
for (const record of registry.filter(matchFn)) {
|
||||
const rootDir = path.resolve(record.rootDir);
|
||||
// Check for the built artifact first, then probe source extensions.
|
||||
const builtCandidate = path.join(rootDir, artifactBasename);
|
||||
if (fs.existsSync(builtCandidate)) {
|
||||
return { modulePath: builtCandidate, boundaryRoot: rootDir };
|
||||
}
|
||||
for (const ext of PUBLIC_SURFACE_SOURCE_EXTENSIONS) {
|
||||
const sourceCandidate = path.join(rootDir, `${sourceBaseName}${ext}`);
|
||||
if (fs.existsSync(sourceCandidate)) {
|
||||
return { modulePath: sourceCandidate, boundaryRoot: rootDir };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveFacadeModuleLocation(params: {
|
||||
dirName: string;
|
||||
artifactBasename: string;
|
||||
@@ -88,9 +125,26 @@ function resolveFacadeModuleLocation(params: {
|
||||
dirName: params.dirName,
|
||||
artifactBasename: params.artifactBasename,
|
||||
});
|
||||
if (!modulePath) {
|
||||
return null;
|
||||
if (modulePath) {
|
||||
return {
|
||||
modulePath,
|
||||
boundaryRoot:
|
||||
bundledPluginsDir && modulePath.startsWith(path.resolve(bundledPluginsDir) + path.sep)
|
||||
? path.resolve(bundledPluginsDir)
|
||||
: OPENCLAW_PACKAGE_ROOT,
|
||||
};
|
||||
}
|
||||
// Bundled directory did not contain the module; fall through to the
|
||||
// registry-based lookup so globally-installed plugins are reachable.
|
||||
return resolveRegistryPluginModuleLocation(params);
|
||||
}
|
||||
const modulePath = resolveBundledPluginPublicSurfacePath({
|
||||
rootDir: OPENCLAW_PACKAGE_ROOT,
|
||||
...(bundledPluginsDir ? { bundledPluginsDir } : {}),
|
||||
dirName: params.dirName,
|
||||
artifactBasename: params.artifactBasename,
|
||||
});
|
||||
if (modulePath) {
|
||||
return {
|
||||
modulePath,
|
||||
boundaryRoot:
|
||||
@@ -99,22 +153,9 @@ function resolveFacadeModuleLocation(params: {
|
||||
: OPENCLAW_PACKAGE_ROOT,
|
||||
};
|
||||
}
|
||||
const modulePath = resolveBundledPluginPublicSurfacePath({
|
||||
rootDir: OPENCLAW_PACKAGE_ROOT,
|
||||
...(bundledPluginsDir ? { bundledPluginsDir } : {}),
|
||||
dirName: params.dirName,
|
||||
artifactBasename: params.artifactBasename,
|
||||
});
|
||||
if (!modulePath) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
modulePath,
|
||||
boundaryRoot:
|
||||
bundledPluginsDir && modulePath.startsWith(path.resolve(bundledPluginsDir) + path.sep)
|
||||
? path.resolve(bundledPluginsDir)
|
||||
: OPENCLAW_PACKAGE_ROOT,
|
||||
};
|
||||
// Bundled directory did not contain the module; fall through to the
|
||||
// registry-based lookup so globally-installed plugins are reachable.
|
||||
return resolveRegistryPluginModuleLocation(params);
|
||||
}
|
||||
|
||||
function getJiti(modulePath: string) {
|
||||
@@ -204,7 +245,18 @@ function resolveBundledPluginManifestRecord(params: {
|
||||
}
|
||||
}
|
||||
|
||||
return registry.find((plugin) => path.basename(plugin.rootDir) === params.dirName) ?? null;
|
||||
// Fallback: match by plugin id first (most semantically precise), then by
|
||||
// rootDir basename, then by declared channel id (loosest). Globally-installed
|
||||
// plugins may have a rootDir whose basename differs from the facade dirName
|
||||
// (e.g. encoded scoped package names), and duplicate-resolution may have
|
||||
// replaced the bundled record with a global one whose rootDir no longer sits
|
||||
// under the bundled plugins directory.
|
||||
return (
|
||||
registry.find((plugin) => plugin.id === params.dirName) ??
|
||||
registry.find((plugin) => path.basename(plugin.rootDir) === params.dirName) ??
|
||||
registry.find((plugin) => plugin.channels.includes(params.dirName)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTrackedFacadePluginId(params: {
|
||||
@@ -343,7 +395,12 @@ export function loadBundledPluginPublicSurfaceModuleSync<T extends object>(param
|
||||
boundaryLabel:
|
||||
location.boundaryRoot === OPENCLAW_PACKAGE_ROOT
|
||||
? "OpenClaw package root"
|
||||
: "bundled plugin directory",
|
||||
: (() => {
|
||||
const bundledDir = resolveBundledPluginsDir();
|
||||
return bundledDir && path.resolve(location.boundaryRoot) === path.resolve(bundledDir)
|
||||
? "bundled plugin directory"
|
||||
: "plugin root";
|
||||
})(),
|
||||
rejectHardlinks: false,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
|
||||
Reference in New Issue
Block a user