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:
Chunyue Wang
2026-04-06 10:33:18 +08:00
committed by GitHub
parent a391e5723a
commit 1e9289f535
3 changed files with 223 additions and 20 deletions

View File

@@ -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({});