mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Jasmine Zhang
Vincent Koc
parent
b3f315461b
commit
bcb016a528
@@ -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");
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user