fix: accept mixed source/dist bundled roots (#93119)

* fix: accept mixed source/dist bundled roots fixes #87730

* fix(plugins): validate mixed bundled roots per plugin

* fix(plugins): preserve active source overlays

---------

Co-authored-by: Jasmine Zhang <jasminezhang@JasminedeMac-mini.local>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
dongdong
2026-06-15 14:00:48 +08:00
committed by GitHub
co-authored by Jasmine Zhang Vincent Koc
parent b3f315461b
commit bcb016a528
2 changed files with 194 additions and 3 deletions
@@ -67,6 +67,44 @@ function writePackagePlugin(rootDir: string, options: { configPaths?: readonly s
);
}
function writeBundledPlugin(rootDir: string, pluginId: string, entryPath: string) {
fs.mkdirSync(rootDir, { recursive: true });
fs.writeFileSync(path.join(rootDir, entryPath), "export default { register() {} };\n", "utf8");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: pluginId,
name: pluginId,
description: pluginId,
configSchema: { type: "object" },
}),
"utf8",
);
fs.writeFileSync(
path.join(rootDir, "package.json"),
JSON.stringify({
name: `@openclaw/${pluginId}`,
version: "1.0.0",
openclaw: { extensions: [`./${entryPath}`] },
}),
"utf8",
);
}
function mockLinuxMountInfo(mountPoints: readonly string[]) {
const originalReadFileSync = fs.readFileSync;
return vi.spyOn(fs, "readFileSync").mockImplementation((filePath, options) => {
if (filePath === "/proc/self/mountinfo") {
return mountPoints
.map(
(mountPoint, index) => `${100 + index} 99 0:${index} / ${mountPoint} rw - tmpfs tmpfs rw`,
)
.join("\n");
}
return originalReadFileSync(filePath, options as never) as never;
});
}
function createCandidate(rootDir: string, pluginId = "demo"): PluginCandidate {
fs.mkdirSync(rootDir, { recursive: true });
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default { register() {} };\n", "utf8");
@@ -753,6 +791,108 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source");
});
it("keeps mixed source-checkout bundled roots from the same checkout", () => {
const tempRoot = makeTempDir();
const packageRoot = path.join(tempRoot, "openclaw");
const bundledRoot = path.join(packageRoot, "dist", "extensions");
const sourceRoot = path.join(packageRoot, "extensions");
const stateDir = path.join(tempRoot, "state");
const env = {
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_VERSION: "2026.4.26",
VITEST: "true",
};
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
fs.writeFileSync(path.join(packageRoot, ".git"), "gitdir: /tmp/mock\n", "utf8");
fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n", "utf8");
writeBundledPlugin(path.join(bundledRoot, "codex"), "codex", "index.js");
writeBundledPlugin(path.join(sourceRoot, "whatsapp"), "whatsapp", "index.ts");
const index = loadInstalledPluginIndex({ config: {}, env, stateDir });
expect(index.plugins.map((plugin) => plugin.pluginId)).toEqual(["codex", "whatsapp"]);
expect(index.plugins.map((plugin) => plugin.rootDir)).toEqual([
fs.realpathSync(path.join(bundledRoot, "codex")),
fs.realpathSync(path.join(sourceRoot, "whatsapp")),
]);
writePersistedInstalledPluginIndexSync(index, { stateDir });
const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir });
expect(result.source).toBe("persisted");
expect(result.diagnostics).toStrictEqual([]);
expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["codex", "whatsapp"]);
});
it("treats a persisted source bundled root as stale once its built peer appears", () => {
const tempRoot = makeTempDir();
const packageRoot = path.join(tempRoot, "openclaw");
const bundledRoot = path.join(packageRoot, "dist", "extensions");
const sourceRoot = path.join(packageRoot, "extensions");
const stateDir = path.join(tempRoot, "state");
const env = {
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_VERSION: "2026.4.26",
VITEST: "true",
};
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
fs.mkdirSync(bundledRoot, { recursive: true });
fs.writeFileSync(path.join(packageRoot, ".git"), "gitdir: /tmp/mock\n", "utf8");
fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n", "utf8");
writeBundledPlugin(path.join(sourceRoot, "whatsapp"), "whatsapp", "index.ts");
const sourceIndex = loadInstalledPluginIndex({ config: {}, env, stateDir });
expect(sourceIndex.plugins.map((plugin) => plugin.rootDir)).toEqual([
fs.realpathSync(path.join(sourceRoot, "whatsapp")),
]);
writePersistedInstalledPluginIndexSync(sourceIndex, { stateDir });
writeBundledPlugin(path.join(bundledRoot, "whatsapp"), "whatsapp", "index.js");
const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir });
expect(result.source).toBe("derived");
expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source");
expect(result.snapshot.plugins.map((plugin) => plugin.rootDir)).toEqual([
fs.realpathSync(path.join(bundledRoot, "whatsapp")),
]);
});
it("keeps a persisted bind-mounted source overlay when its built peer exists", () => {
const tempRoot = makeTempDir();
const packageRoot = path.join(tempRoot, "openclaw");
const bundledRoot = path.join(packageRoot, "dist", "extensions");
const sourcePluginDir = path.join(packageRoot, "extensions", "whatsapp");
const stateDir = path.join(tempRoot, "state");
const env = {
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_VERSION: "2026.4.26",
VITEST: "true",
};
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
fs.mkdirSync(bundledRoot, { recursive: true });
fs.writeFileSync(path.join(packageRoot, ".git"), "gitdir: /tmp/mock\n", "utf8");
fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n", "utf8");
writeBundledPlugin(sourcePluginDir, "whatsapp", "index.ts");
const sourceIndex = loadInstalledPluginIndex({ config: {}, env, stateDir });
writePersistedInstalledPluginIndexSync(sourceIndex, { stateDir });
writeBundledPlugin(path.join(bundledRoot, "whatsapp"), "whatsapp", "index.js");
mockLinuxMountInfo([sourcePluginDir]);
const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir });
expect(result.source).toBe("persisted");
expect(result.diagnostics).toStrictEqual([]);
expect(result.snapshot.plugins.map((plugin) => plugin.rootDir)).toEqual([
fs.realpathSync(sourcePluginDir),
]);
});
it("treats persisted registry as stale when a plugin diagnostic source path no longer exists", () => {
const tempRoot = makeTempDir();
const stateDir = path.join(tempRoot, "state");
+54 -3
View File
@@ -5,6 +5,8 @@ import path from "node:path";
import { resolveUserPath } from "../utils.js";
import { resolveCompatibilityHostVersion } from "../version.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import { buildLegacyBundledRootPath } from "./bundled-load-path-aliases.js";
import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js";
import { normalizePluginsConfig } from "./config-state.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import type { PluginDiscoveryResult } from "./discovery.js";
@@ -328,9 +330,58 @@ function hasMismatchedPersistedBundledPluginRoot(
if (!bundledPluginsDir) {
return false;
}
return index.plugins.some(
(plugin) =>
plugin.origin === "bundled" && !isPathInsideOrEqual(plugin.rootDir, bundledPluginsDir),
let sourceOverlayDirs: string[] | undefined;
return index.plugins.some((plugin) => {
if (plugin.origin !== "bundled" || isPathInsideOrEqual(plugin.rootDir, bundledPluginsDir)) {
return false;
}
sourceOverlayDirs ??= listBundledSourceOverlayDirs({
bundledRoot: bundledPluginsDir,
env,
});
return !isAllowedPersistedBundledPluginRoot(
plugin.rootDir,
bundledPluginsDir,
sourceOverlayDirs,
);
});
}
function isAllowedPersistedBundledPluginRoot(
pluginRootDir: string,
bundledPluginsDir: string,
sourceOverlayDirs: readonly string[],
): boolean {
if (isPathInsideOrEqual(pluginRootDir, bundledPluginsDir)) {
return true;
}
if (sourceOverlayDirs.some((overlayDir) => isPathInsideOrEqual(pluginRootDir, overlayDir))) {
return true;
}
const legacyRoot = buildLegacyBundledRootPath(bundledPluginsDir);
if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) {
return false;
}
const relativePluginRoot = path.relative(
resolveComparablePath(legacyRoot),
resolveComparablePath(pluginRootDir),
);
if (!isRelativePathInsideOrEqual(relativePluginRoot)) {
return false;
}
// Discovery prefers a built plugin whenever the same child exists in the
// packaged root. Keep source-only bundled plugins, but invalidate stale
// source records once their built peer appears.
return !fs.existsSync(path.join(bundledPluginsDir, relativePluginRoot));
}
function isSourceCheckoutBundledPluginRoot(extensionsDir: string): boolean {
const packageRoot = path.dirname(extensionsDir);
return (
fs.existsSync(extensionsDir) &&
fs.existsSync(path.join(packageRoot, ".git")) &&
fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) &&
fs.existsSync(path.join(packageRoot, "src"))
);
}