Skip to content

Commit bcb016a

Browse files
arkyu2077Jasmine Zhangvincentkoc
authored
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 <[email protected]> Co-authored-by: Vincent Koc <[email protected]>
1 parent b3f3154 commit bcb016a

2 files changed

Lines changed: 194 additions & 3 deletions

File tree

src/plugins/plugin-registry-snapshot.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,44 @@ function writePackagePlugin(rootDir: string, options: { configPaths?: readonly s
6767
);
6868
}
6969

70+
function writeBundledPlugin(rootDir: string, pluginId: string, entryPath: string) {
71+
fs.mkdirSync(rootDir, { recursive: true });
72+
fs.writeFileSync(path.join(rootDir, entryPath), "export default { register() {} };\n", "utf8");
73+
fs.writeFileSync(
74+
path.join(rootDir, "openclaw.plugin.json"),
75+
JSON.stringify({
76+
id: pluginId,
77+
name: pluginId,
78+
description: pluginId,
79+
configSchema: { type: "object" },
80+
}),
81+
"utf8",
82+
);
83+
fs.writeFileSync(
84+
path.join(rootDir, "package.json"),
85+
JSON.stringify({
86+
name: `@openclaw/${pluginId}`,
87+
version: "1.0.0",
88+
openclaw: { extensions: [`./${entryPath}`] },
89+
}),
90+
"utf8",
91+
);
92+
}
93+
94+
function mockLinuxMountInfo(mountPoints: readonly string[]) {
95+
const originalReadFileSync = fs.readFileSync;
96+
return vi.spyOn(fs, "readFileSync").mockImplementation((filePath, options) => {
97+
if (filePath === "/proc/self/mountinfo") {
98+
return mountPoints
99+
.map(
100+
(mountPoint, index) => `${100 + index} 99 0:${index} / ${mountPoint} rw - tmpfs tmpfs rw`,
101+
)
102+
.join("\n");
103+
}
104+
return originalReadFileSync(filePath, options as never) as never;
105+
});
106+
}
107+
70108
function createCandidate(rootDir: string, pluginId = "demo"): PluginCandidate {
71109
fs.mkdirSync(rootDir, { recursive: true });
72110
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default { register() {} };\n", "utf8");
@@ -753,6 +791,108 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
753791
expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source");
754792
});
755793

794+
it("keeps mixed source-checkout bundled roots from the same checkout", () => {
795+
const tempRoot = makeTempDir();
796+
const packageRoot = path.join(tempRoot, "openclaw");
797+
const bundledRoot = path.join(packageRoot, "dist", "extensions");
798+
const sourceRoot = path.join(packageRoot, "extensions");
799+
const stateDir = path.join(tempRoot, "state");
800+
const env = {
801+
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
802+
OPENCLAW_STATE_DIR: stateDir,
803+
OPENCLAW_VERSION: "2026.4.26",
804+
VITEST: "true",
805+
};
806+
807+
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
808+
fs.writeFileSync(path.join(packageRoot, ".git"), "gitdir: /tmp/mock\n", "utf8");
809+
fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n", "utf8");
810+
writeBundledPlugin(path.join(bundledRoot, "codex"), "codex", "index.js");
811+
writeBundledPlugin(path.join(sourceRoot, "whatsapp"), "whatsapp", "index.ts");
812+
813+
const index = loadInstalledPluginIndex({ config: {}, env, stateDir });
814+
expect(index.plugins.map((plugin) => plugin.pluginId)).toEqual(["codex", "whatsapp"]);
815+
expect(index.plugins.map((plugin) => plugin.rootDir)).toEqual([
816+
fs.realpathSync(path.join(bundledRoot, "codex")),
817+
fs.realpathSync(path.join(sourceRoot, "whatsapp")),
818+
]);
819+
writePersistedInstalledPluginIndexSync(index, { stateDir });
820+
821+
const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir });
822+
823+
expect(result.source).toBe("persisted");
824+
expect(result.diagnostics).toStrictEqual([]);
825+
expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["codex", "whatsapp"]);
826+
});
827+
828+
it("treats a persisted source bundled root as stale once its built peer appears", () => {
829+
const tempRoot = makeTempDir();
830+
const packageRoot = path.join(tempRoot, "openclaw");
831+
const bundledRoot = path.join(packageRoot, "dist", "extensions");
832+
const sourceRoot = path.join(packageRoot, "extensions");
833+
const stateDir = path.join(tempRoot, "state");
834+
const env = {
835+
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
836+
OPENCLAW_STATE_DIR: stateDir,
837+
OPENCLAW_VERSION: "2026.4.26",
838+
VITEST: "true",
839+
};
840+
841+
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
842+
fs.mkdirSync(bundledRoot, { recursive: true });
843+
fs.writeFileSync(path.join(packageRoot, ".git"), "gitdir: /tmp/mock\n", "utf8");
844+
fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n", "utf8");
845+
writeBundledPlugin(path.join(sourceRoot, "whatsapp"), "whatsapp", "index.ts");
846+
847+
const sourceIndex = loadInstalledPluginIndex({ config: {}, env, stateDir });
848+
expect(sourceIndex.plugins.map((plugin) => plugin.rootDir)).toEqual([
849+
fs.realpathSync(path.join(sourceRoot, "whatsapp")),
850+
]);
851+
writePersistedInstalledPluginIndexSync(sourceIndex, { stateDir });
852+
writeBundledPlugin(path.join(bundledRoot, "whatsapp"), "whatsapp", "index.js");
853+
854+
const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir });
855+
856+
expect(result.source).toBe("derived");
857+
expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source");
858+
expect(result.snapshot.plugins.map((plugin) => plugin.rootDir)).toEqual([
859+
fs.realpathSync(path.join(bundledRoot, "whatsapp")),
860+
]);
861+
});
862+
863+
it("keeps a persisted bind-mounted source overlay when its built peer exists", () => {
864+
const tempRoot = makeTempDir();
865+
const packageRoot = path.join(tempRoot, "openclaw");
866+
const bundledRoot = path.join(packageRoot, "dist", "extensions");
867+
const sourcePluginDir = path.join(packageRoot, "extensions", "whatsapp");
868+
const stateDir = path.join(tempRoot, "state");
869+
const env = {
870+
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
871+
OPENCLAW_STATE_DIR: stateDir,
872+
OPENCLAW_VERSION: "2026.4.26",
873+
VITEST: "true",
874+
};
875+
876+
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
877+
fs.mkdirSync(bundledRoot, { recursive: true });
878+
fs.writeFileSync(path.join(packageRoot, ".git"), "gitdir: /tmp/mock\n", "utf8");
879+
fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n", "utf8");
880+
writeBundledPlugin(sourcePluginDir, "whatsapp", "index.ts");
881+
882+
const sourceIndex = loadInstalledPluginIndex({ config: {}, env, stateDir });
883+
writePersistedInstalledPluginIndexSync(sourceIndex, { stateDir });
884+
writeBundledPlugin(path.join(bundledRoot, "whatsapp"), "whatsapp", "index.js");
885+
mockLinuxMountInfo([sourcePluginDir]);
886+
887+
const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir });
888+
889+
expect(result.source).toBe("persisted");
890+
expect(result.diagnostics).toStrictEqual([]);
891+
expect(result.snapshot.plugins.map((plugin) => plugin.rootDir)).toEqual([
892+
fs.realpathSync(sourcePluginDir),
893+
]);
894+
});
895+
756896
it("treats persisted registry as stale when a plugin diagnostic source path no longer exists", () => {
757897
const tempRoot = makeTempDir();
758898
const stateDir = path.join(tempRoot, "state");

src/plugins/plugin-registry-snapshot.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import path from "node:path";
55
import { resolveUserPath } from "../utils.js";
66
import { resolveCompatibilityHostVersion } from "../version.js";
77
import { resolveBundledPluginsDir } from "./bundled-dir.js";
8+
import { buildLegacyBundledRootPath } from "./bundled-load-path-aliases.js";
9+
import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js";
810
import { normalizePluginsConfig } from "./config-state.js";
911
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
1012
import type { PluginDiscoveryResult } from "./discovery.js";
@@ -328,9 +330,58 @@ function hasMismatchedPersistedBundledPluginRoot(
328330
if (!bundledPluginsDir) {
329331
return false;
330332
}
331-
return index.plugins.some(
332-
(plugin) =>
333-
plugin.origin === "bundled" && !isPathInsideOrEqual(plugin.rootDir, bundledPluginsDir),
333+
let sourceOverlayDirs: string[] | undefined;
334+
return index.plugins.some((plugin) => {
335+
if (plugin.origin !== "bundled" || isPathInsideOrEqual(plugin.rootDir, bundledPluginsDir)) {
336+
return false;
337+
}
338+
sourceOverlayDirs ??= listBundledSourceOverlayDirs({
339+
bundledRoot: bundledPluginsDir,
340+
env,
341+
});
342+
return !isAllowedPersistedBundledPluginRoot(
343+
plugin.rootDir,
344+
bundledPluginsDir,
345+
sourceOverlayDirs,
346+
);
347+
});
348+
}
349+
350+
function isAllowedPersistedBundledPluginRoot(
351+
pluginRootDir: string,
352+
bundledPluginsDir: string,
353+
sourceOverlayDirs: readonly string[],
354+
): boolean {
355+
if (isPathInsideOrEqual(pluginRootDir, bundledPluginsDir)) {
356+
return true;
357+
}
358+
if (sourceOverlayDirs.some((overlayDir) => isPathInsideOrEqual(pluginRootDir, overlayDir))) {
359+
return true;
360+
}
361+
const legacyRoot = buildLegacyBundledRootPath(bundledPluginsDir);
362+
if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) {
363+
return false;
364+
}
365+
const relativePluginRoot = path.relative(
366+
resolveComparablePath(legacyRoot),
367+
resolveComparablePath(pluginRootDir),
368+
);
369+
if (!isRelativePathInsideOrEqual(relativePluginRoot)) {
370+
return false;
371+
}
372+
// Discovery prefers a built plugin whenever the same child exists in the
373+
// packaged root. Keep source-only bundled plugins, but invalidate stale
374+
// source records once their built peer appears.
375+
return !fs.existsSync(path.join(bundledPluginsDir, relativePluginRoot));
376+
}
377+
378+
function isSourceCheckoutBundledPluginRoot(extensionsDir: string): boolean {
379+
const packageRoot = path.dirname(extensionsDir);
380+
return (
381+
fs.existsSync(extensionsDir) &&
382+
fs.existsSync(path.join(packageRoot, ".git")) &&
383+
fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) &&
384+
fs.existsSync(path.join(packageRoot, "src"))
334385
);
335386
}
336387

0 commit comments

Comments
 (0)