fix: load staged dist-runtime plugins in docker

This commit is contained in:
Peter Steinberger
2026-04-22 18:20:40 +01:00
parent 72c765e736
commit a2512f0243
13 changed files with 311 additions and 12 deletions

View File

@@ -603,6 +603,50 @@ describe("ensureBundledPluginRuntimeDeps", () => {
expect(resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} })).toBe(pluginRoot);
});
it("treats Docker build source trees without .git as source checkouts", () => {
const packageRoot = makeTempDir();
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages:\n - .\n");
const pluginRoot = path.join(packageRoot, "extensions", "acpx");
fs.mkdirSync(pluginRoot, { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify({
dependencies: {
acpx: "0.5.3",
},
devDependencies: {
"@openclaw/plugin-sdk": "workspace:*",
},
}),
);
const calls: BundledRuntimeDepsInstallParams[] = [];
const result = ensureBundledPluginRuntimeDeps({
env: {},
installDeps: (params) => {
calls.push(params);
},
pluginId: "acpx",
pluginRoot,
});
expect(result).toEqual({
installedSpecs: ["acpx@0.5.3"],
retainSpecs: ["acpx@0.5.3"],
});
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: expect.stringContaining(
path.join(".local", "bundled-plugin-runtime-deps"),
),
missingSpecs: ["acpx@0.5.3"],
installSpecs: ["acpx@0.5.3"],
},
]);
});
it("does not trust package-root runtime deps for source-checkout bundled plugins", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();

View File

@@ -172,7 +172,8 @@ function collectRuntimeDeps(packageJson: JsonObject): Record<string, unknown> {
function isSourceCheckoutRoot(packageRoot: string): boolean {
return (
fs.existsSync(path.join(packageRoot, ".git")) &&
(fs.existsSync(path.join(packageRoot, ".git")) ||
fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml"))) &&
fs.existsSync(path.join(packageRoot, "src")) &&
fs.existsSync(path.join(packageRoot, "extensions"))
);

View File

@@ -1262,6 +1262,102 @@ module.exports = {
expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded");
});
it("loads dist-runtime wrappers from an external stage dir", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();
const bundledDir = path.join(packageRoot, "dist-runtime", "extensions");
const pluginRoot = path.join(bundledDir, "acpx");
const canonicalPluginRoot = path.join(packageRoot, "dist", "extensions", "acpx");
fs.mkdirSync(pluginRoot, { recursive: true });
fs.mkdirSync(canonicalPluginRoot, { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "index.js"),
[
`export * from "../../../dist/extensions/acpx/index.js";`,
`import defaultModule from "../../../dist/extensions/acpx/index.js";`,
`export default defaultModule;`,
"",
].join("\n"),
"utf-8",
);
fs.writeFileSync(
path.join(canonicalPluginRoot, "index.js"),
[
`import runtimeDep from "external-runtime";`,
`export default {`,
` id: "acpx",`,
` register(api) {`,
` api.registerCommand({ name: "external-runtime", handler: () => runtimeDep.marker });`,
` },`,
`};`,
"",
].join("\n"),
"utf-8",
);
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir;
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify(
{
name: "@openclaw/acpx",
version: "1.0.0",
type: "module",
dependencies: {
"external-runtime": "1.0.0",
},
openclaw: { extensions: ["./index.js"] },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginRoot, "openclaw.plugin.json"),
JSON.stringify(
{
id: "acpx",
enabledByDefault: true,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
enabled: true,
},
},
bundledRuntimeDepsInstaller: ({ installRoot }) => {
const depRoot = path.join(installRoot, "node_modules", "external-runtime");
fs.mkdirSync(depRoot, { recursive: true });
fs.writeFileSync(
path.join(depRoot, "package.json"),
JSON.stringify({
name: "external-runtime",
version: "1.0.0",
type: "module",
exports: "./index.js",
}),
"utf-8",
);
fs.writeFileSync(
path.join(depRoot, "index.js"),
"export default { marker: 'dist-runtime-ok' };\n",
"utf-8",
);
},
});
expect(registry.plugins.find((entry) => entry.id === "acpx")?.status).toBe("loaded");
});
it("loads source-checkout bundled runtime deps without mirroring the repo tree", () => {
const packageRoot = makeTempDir();
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });

View File

@@ -437,7 +437,8 @@ function toSafeImportPath(specifier: string): string {
function createPluginJitiLoader(options: Pick<PluginLoadOptions, "pluginSdkResolution">) {
const jitiLoaders: PluginJitiLoaderCache = new Map();
return (modulePath: string) => {
const tryNative = shouldPreferNativeJiti(modulePath);
const tryNative =
shouldPreferNativeJiti(modulePath) && !isBundledRuntimeDependencyMirrorPath(modulePath);
return getCachedPluginJitiLoader({
cache: jitiLoaders,
modulePath,
@@ -453,8 +454,32 @@ function createPluginJitiLoader(options: Pick<PluginLoadOptions, "pluginSdkResol
};
}
function resolveCanonicalDistRuntimeSource(source: string): string {
const marker = `${path.sep}dist-runtime${path.sep}extensions${path.sep}`;
const index = source.indexOf(marker);
if (index === -1) {
return source;
}
const candidate = `${source.slice(0, index)}${path.sep}dist${path.sep}extensions${path.sep}${source.slice(index + marker.length)}`;
return fs.existsSync(candidate) ? candidate : source;
}
const registeredBundledRuntimeDepNodePaths = new Set<string>();
function isBundledRuntimeDependencyMirrorPath(modulePath: string): boolean {
const resolvedModulePath = path.resolve(modulePath);
for (const nodeModulesDir of registeredBundledRuntimeDepNodePaths) {
const installRoot = path.dirname(nodeModulesDir);
if (
resolvedModulePath === installRoot ||
resolvedModulePath.startsWith(`${installRoot}${path.sep}`)
) {
return true;
}
}
return false;
}
function registerBundledRuntimeDependencyNodePath(installRoot: string): void {
const nodeModulesDir = path.join(installRoot, "node_modules");
if (registeredBundledRuntimeDepNodePaths.has(nodeModulesDir) || !fs.existsSync(nodeModulesDir)) {
@@ -529,7 +554,8 @@ function prepareBundledPluginRuntimeDistMirror(params: {
}): string {
const sourceExtensionsRoot = path.dirname(params.pluginRoot);
const sourceDistRoot = path.dirname(sourceExtensionsRoot);
const mirrorDistRoot = path.join(params.installRoot, "dist");
const sourceDistRootName = path.basename(sourceDistRoot);
const mirrorDistRoot = path.join(params.installRoot, sourceDistRootName);
const mirrorExtensionsRoot = path.join(mirrorDistRoot, "extensions");
fs.mkdirSync(mirrorExtensionsRoot, { recursive: true, mode: 0o755 });
for (const entry of fs.readdirSync(sourceDistRoot, { withFileTypes: true })) {
@@ -551,6 +577,24 @@ function prepareBundledPluginRuntimeDistMirror(params: {
}
}
}
if (sourceDistRootName === "dist-runtime") {
const sourceCanonicalDistRoot = path.join(path.dirname(sourceDistRoot), "dist");
const targetCanonicalDistRoot = path.join(params.installRoot, "dist");
if (fs.existsSync(sourceCanonicalDistRoot)) {
const targetMatchesSource =
fs.existsSync(targetCanonicalDistRoot) &&
safeRealpathOrResolve(targetCanonicalDistRoot) ===
safeRealpathOrResolve(sourceCanonicalDistRoot);
if (!targetMatchesSource) {
fs.rmSync(targetCanonicalDistRoot, { recursive: true, force: true });
try {
fs.symlinkSync(sourceCanonicalDistRoot, targetCanonicalDistRoot, "junction");
} catch {
copyBundledPluginRuntimeRoot(sourceCanonicalDistRoot, targetCanonicalDistRoot);
}
}
}
}
return mirrorExtensionsRoot;
}
@@ -938,7 +982,33 @@ function resolvePluginModuleExport(moduleExport: unknown): {
definition?: OpenClawPluginDefinition;
register?: OpenClawPluginDefinition["register"];
} {
const resolved = unwrapDefaultModuleExport(moduleExport);
const seen = new Set<unknown>();
const candidates: unknown[] = [unwrapDefaultModuleExport(moduleExport), moduleExport];
for (let index = 0; index < candidates.length && index < 12; index += 1) {
const resolved = candidates[index];
if (seen.has(resolved)) {
continue;
}
seen.add(resolved);
if (typeof resolved === "function") {
return {
register: resolved as OpenClawPluginDefinition["register"],
};
}
if (resolved && typeof resolved === "object") {
const def = resolved as OpenClawPluginDefinition;
const register = def.register ?? def.activate;
if (typeof register === "function") {
return { definition: def, register };
}
for (const key of ["default", "module"]) {
if (key in def) {
candidates.push((def as Record<string, unknown>)[key]);
}
}
}
}
const resolved = candidates[0];
if (typeof resolved === "function") {
return {
register: resolved as OpenClawPluginDefinition["register"],
@@ -2132,9 +2202,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
runtimeSetupSource
? runtimeSetupSource
: runtimeCandidateSource;
const moduleLoadSource = resolveCanonicalDistRuntimeSource(loadSource);
const moduleRoot = resolveCanonicalDistRuntimeSource(runtimePluginRoot);
const opened = openBoundaryFileSync({
absolutePath: loadSource,
rootPath: runtimePluginRoot,
absolutePath: moduleLoadSource,
rootPath: moduleRoot,
boundaryLabel: "plugin root",
rejectHardlinks: candidate.origin !== "bundled",
skipLexicalRootCheck: true,

View File

@@ -53,6 +53,23 @@ function expectRuntimePluginWrapperContains(params: {
expect(fs.readFileSync(runtimePath, "utf8")).toContain(params.expectedImport);
}
function expectRuntimePluginWrapperForwardsDefault(params: {
repoRoot: string;
pluginId: string;
expectedImport: string;
}) {
const runtimePath = path.join(
params.repoRoot,
"dist-runtime",
"extensions",
params.pluginId,
"index.js",
);
expect(fs.readFileSync(runtimePath, "utf8")).toContain(
`import defaultModule from "${params.expectedImport}";`,
);
}
function expectRuntimeArtifactText(params: {
repoRoot: string;
pluginId: string;
@@ -102,6 +119,11 @@ describe("stageBundledPluginRuntime", () => {
pluginId: "diffs",
expectedImport: distRuntimeImportPath("diffs"),
});
expectRuntimePluginWrapperForwardsDefault({
repoRoot,
pluginId: "diffs",
expectedImport: distRuntimeImportPath("diffs"),
});
expect(fs.lstatSync(path.join(runtimePluginDir, "node_modules")).isSymbolicLink()).toBe(true);
expect(fs.realpathSync(path.join(runtimePluginDir, "node_modules"))).toBe(
fs.realpathSync(path.join(distPluginDir, "node_modules")),