fix(docker): package selected external plugins in source-built images (#103629)

* build(docker): package selected external plugins

* test(docker): auto-clean plugin fixture

* fix(docker): preserve selected image provenance

* fix(docker): accept manifest-only plugin selections

* fix(docker): resolve selected plugin manifest ids

* fix(docker): isolate resolved plugin selection
This commit is contained in:
Peter Steinberger
2026-07-10 13:00:50 +01:00
committed by GitHub
parent 13819996ad
commit a238dead67
21 changed files with 807 additions and 62 deletions

View File

@@ -1,13 +1,17 @@
// Bundled Plugin Build Entries tests cover bundled plugin build entries script behavior.
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
collectRootPackageExcludedExtensionDirs,
DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV,
listBundledPluginBuildEntries,
listBundledPluginPackArtifacts,
} from "../../scripts/lib/bundled-plugin-build-entries.mjs";
import { expectNoNodeFsScans } from "../../src/test-utils/fs-scan-assertions.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function expectNoPrefixMatches(values: string[], prefix: string) {
expect(values.filter((value) => value.startsWith(prefix))).toEqual([]);
@@ -201,6 +205,143 @@ describe("bundled plugin build entries", () => {
}
});
it("builds explicitly selected external plugins only for Docker", () => {
const baselineEnv = { ...process.env };
delete baselineEnv[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV];
const dockerEnv = {
...baselineEnv,
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "slack clickclack,slack,msteams",
};
const entries = listBundledPluginBuildEntries({ env: dockerEnv });
const baselineArtifacts = listBundledPluginPackArtifacts({ env: baselineEnv });
const artifacts = listBundledPluginPackArtifacts({ env: dockerEnv });
const reorderedEntries = listBundledPluginBuildEntries({
env: {
...baselineEnv,
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "msteams,clickclack slack",
},
});
const entryKeys = Object.keys(entries);
expect(entries["extensions/clickclack/index"]).toBe("extensions/clickclack/index.ts");
expect(entries["extensions/slack/index"]).toBe("extensions/slack/index.ts");
expect(entries["extensions/slack/setup-entry"]).toBe("extensions/slack/setup-entry.ts");
expect(entries["extensions/msteams/index"]).toBe("extensions/msteams/index.ts");
expect(entries["extensions/clawrouter/index"]).toBe("extensions/clawrouter/index.ts");
expect(entryKeys.findIndex((entry) => entry.startsWith("extensions/clickclack/"))).toBeLessThan(
entryKeys.findIndex((entry) => entry.startsWith("extensions/slack/")),
);
expect(Object.keys(reorderedEntries)).toEqual(entryKeys);
expect(artifacts).toEqual(baselineArtifacts);
expectNoPrefixMatches(artifacts, "dist/extensions/clickclack/");
expectNoPrefixMatches(artifacts, "dist/extensions/msteams/");
expectNoPrefixMatches(artifacts, "dist/extensions/slack/");
});
it("sorts Docker-selected build entries without git metadata", () => {
const repoDir = tempDirs.make("openclaw-docker-build-entries-");
const extensionsDir = path.join(repoDir, "extensions");
for (const pluginId of ["clickclack", "msteams", "slack"]) {
const pluginDir = path.join(extensionsDir, pluginId);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {};\n");
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
`${JSON.stringify({ id: pluginId })}\n`,
);
fs.writeFileSync(
path.join(pluginDir, "package.json"),
`${JSON.stringify({
name: `@openclaw/${pluginId}`,
openclaw: {
extensions: ["./index.ts"],
build: { bundledDist: false },
},
})}\n`,
);
}
const unsortedDirents = fs.readdirSync(extensionsDir, { withFileTypes: true }).toReversed();
const readdirSpy = vi
.spyOn(fs, "readdirSync")
.mockImplementationOnce(() => unsortedDirents as never);
try {
expect(
Object.keys(
listBundledPluginBuildEntries({
cwd: repoDir,
env: {
...process.env,
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "slack,msteams,clickclack",
},
}),
),
).toEqual([
"extensions/clickclack/index",
"extensions/msteams/index",
"extensions/slack/index",
]);
} finally {
readdirSpy.mockRestore();
}
});
it("preserves known dependency-only Docker plugin selections", () => {
const baselineEnv = { ...process.env };
delete baselineEnv[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV];
const baselineEntries = listBundledPluginBuildEntries({ env: baselineEnv });
const selectedEntries = listBundledPluginBuildEntries({
env: {
...baselineEnv,
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "whatsapp,qqbot",
},
});
expect(selectedEntries).toEqual(baselineEntries);
expectNoPrefixMatches(Object.keys(selectedEntries), "extensions/qqbot/");
expectNoPrefixMatches(Object.keys(selectedEntries), "extensions/whatsapp/");
});
it("preserves known package-less bundled Docker plugin selections", () => {
const baselineEnv = { ...process.env };
delete baselineEnv[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV];
const baselineEntries = listBundledPluginBuildEntries({ env: baselineEnv });
const selectedEntries = listBundledPluginBuildEntries({
env: {
...baselineEnv,
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "active-memory",
},
});
expect(selectedEntries).toEqual(baselineEntries);
expect(selectedEntries["extensions/active-memory/index"]).toBe(
"extensions/active-memory/index.ts",
);
});
it("rejects unknown and invalid Docker plugin selections", () => {
for (const [selection, message] of [
[
"missing-plugin",
`${DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV} references unknown plugin id(s): missing-plugin`,
],
[
"../clickclack",
`${DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV} contains invalid plugin id(s): ../clickclack`,
],
] as const) {
expect(() =>
listBundledPluginBuildEntries({
env: {
...process.env,
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: selection,
},
}),
).toThrow(message);
}
});
it("keeps Cohere bundled through the externalization transition", () => {
const artifacts = listBundledPluginPackArtifacts();

View File

@@ -0,0 +1,62 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const repoRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url)));
const selectorScript = path.join(repoRoot, "scripts/lib/docker-plugin-selection.mjs");
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function writePlugin(extensionsRoot: string, dirName: string, manifestId?: string) {
const pluginDir = path.join(extensionsRoot, dirName);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, "package.json"), `${JSON.stringify({ name: dirName })}\n`);
if (manifestId) {
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
`${JSON.stringify({ id: manifestId })}\n`,
);
}
}
function runSelector(extensionsRoot: string, selection: string) {
return spawnSync(process.execPath, [selectorScript, extensionsRoot, selection], {
encoding: "utf8",
});
}
describe("Docker plugin selection", () => {
it("resolves manifest ids and source directory names deterministically", () => {
const extensionsRoot = tempDirs.make("openclaw-docker-plugin-selection-");
writePlugin(extensionsRoot, "source-only");
writePlugin(extensionsRoot, "provider-source", "provider-id");
const result = runSelector(
extensionsRoot,
"source-only,provider-id provider-source,provider-id",
);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toBe("provider-source\nsource-only\n");
});
it("fails closed for unknown, invalid, and ambiguous ids", () => {
const extensionsRoot = tempDirs.make("openclaw-docker-plugin-selection-errors-");
writePlugin(extensionsRoot, "shared");
writePlugin(extensionsRoot, "other-source", "shared");
for (const [selection, message] of [
["missing-plugin", "unknown OPENCLAW_EXTENSIONS plugin id: missing-plugin"],
["../invalid", "invalid OPENCLAW_EXTENSIONS plugin id: ../invalid"],
["shared", "ambiguous OPENCLAW_EXTENSIONS plugin id: shared"],
] as const) {
const result = runSelector(extensionsRoot, selection);
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain(message);
}
});
});