From 7e229f0d3d63acc52a831d5017b22e45c4feffa5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 15:11:14 -0700 Subject: [PATCH] fix(docker): prune external plugin dist (#77547) --- CHANGELOG.md | 1 + Dockerfile | 1 + scripts/prune-docker-plugin-dist.d.mts | 6 +++ scripts/prune-docker-plugin-dist.mjs | 52 ++++++++++++++++++ src/dockerfile.test.ts | 3 ++ src/plugins/prune-docker-plugin-dist.test.ts | 56 ++++++++++++++++++++ 6 files changed, 119 insertions(+) create mode 100644 scripts/prune-docker-plugin-dist.d.mts create mode 100644 scripts/prune-docker-plugin-dist.mjs create mode 100644 src/plugins/prune-docker-plugin-dist.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 101b3d967e6..98500da708f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc. +- Docker: prune package-excluded plugin dist directories from runtime images unless the build explicitly opts that plugin in, so official external plugins such as Feishu stay install-on-demand instead of shipping partial metadata without compiled runtime output. Fixes #77424. Thanks @vincentkoc. - CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc. - CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti. - Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo. diff --git a/Dockerfile b/Dockerfile index 60b50869fbc..d14c730132e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -124,6 +124,7 @@ RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \ cp /tmp/pnpm-workspace.runtime.yaml pnpm-workspace.yaml && \ CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod && \ node scripts/postinstall-bundled-plugins.mjs && \ + OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \ find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \ node scripts/check-package-dist-imports.mjs /app diff --git a/scripts/prune-docker-plugin-dist.d.mts b/scripts/prune-docker-plugin-dist.d.mts new file mode 100644 index 00000000000..cdc7d9163bd --- /dev/null +++ b/scripts/prune-docker-plugin-dist.d.mts @@ -0,0 +1,6 @@ +export function parseDockerPluginKeepList(value: unknown): Set; +export function pruneDockerPluginDist(params?: { + cwd?: string; + repoRoot?: string; + env?: NodeJS.ProcessEnv; +}): string[]; diff --git a/scripts/prune-docker-plugin-dist.mjs b/scripts/prune-docker-plugin-dist.mjs new file mode 100644 index 00000000000..cd01d591bc6 --- /dev/null +++ b/scripts/prune-docker-plugin-dist.mjs @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { collectRootPackageExcludedExtensionDirs } from "./lib/bundled-plugin-build-entries.mjs"; +import { removePathIfExists } from "./runtime-postbuild-shared.mjs"; + +function parsePluginList(value) { + if (typeof value !== "string") { + return new Set(); + } + return new Set( + value + .split(/[\s,]+/u) + .map((entry) => entry.trim()) + .filter(Boolean), + ); +} + +export function parseDockerPluginKeepList(value) { + return parsePluginList(value); +} + +export function pruneDockerPluginDist(params = {}) { + const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd(); + const env = params.env ?? process.env; + const keepPluginIds = parseDockerPluginKeepList(env.OPENCLAW_EXTENSIONS); + const excludedPluginIds = collectRootPackageExcludedExtensionDirs({ cwd: repoRoot }); + const removed = []; + + for (const pluginId of [...excludedPluginIds].toSorted((left, right) => + left.localeCompare(right), + )) { + if (keepPluginIds.has(pluginId)) { + continue; + } + + for (const root of ["dist", "dist-runtime"]) { + const pluginDistDir = path.join(repoRoot, root, "extensions", pluginId); + if (!fs.existsSync(pluginDistDir)) { + continue; + } + removePathIfExists(pluginDistDir); + removed.push(path.relative(repoRoot, pluginDistDir).replaceAll("\\", "/")); + } + } + + return removed; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + pruneDockerPluginDist(); +} diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index d550f9dd45d..b795f19f9a0 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -111,6 +111,9 @@ describe("Dockerfile", () => { expect(dockerfile).toContain("pnpm-workspace.runtime.yaml"); expect(dockerfile).toContain(" - ui\\n"); expect(dockerfile).toContain("CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod"); + expect(dockerfile).toContain( + 'OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs', + ); expect(dockerfile).toContain("prune must not rediscover unrelated workspaces"); expect(dockerfile).not.toContain( `npm install --prefix "${BUNDLED_PLUGIN_ROOT_DIR}/$ext" --omit=dev --silent`, diff --git a/src/plugins/prune-docker-plugin-dist.test.ts b/src/plugins/prune-docker-plugin-dist.test.ts new file mode 100644 index 00000000000..d162c83f334 --- /dev/null +++ b/src/plugins/prune-docker-plugin-dist.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + parseDockerPluginKeepList, + pruneDockerPluginDist, +} from "../../scripts/prune-docker-plugin-dist.mjs"; +import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "../../test/helpers/temp-repo.js"; + +const tempDirs: string[] = []; + +function makeRepoRoot(prefix: string): string { + return makeTempRepoRoot(tempDirs, prefix); +} + +function writeDistPluginFile(repoRoot: string, root: "dist" | "dist-runtime", pluginId: string) { + const pluginDir = path.join(repoRoot, root, "extensions", pluginId); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "openclaw.plugin.json"), "{}\n", "utf8"); +} + +afterEach(() => { + cleanupTempDirs(tempDirs); +}); + +describe("pruneDockerPluginDist", () => { + it("parses space and comma separated Docker plugin keep lists", () => { + expect([...parseDockerPluginKeepList("diagnostics-otel feishu,discord")]).toEqual([ + "diagnostics-otel", + "feishu", + "discord", + ]); + }); + + it("removes package-excluded plugin dist unless Docker explicitly opts it in", () => { + const repoRoot = makeRepoRoot("openclaw-docker-plugin-dist-"); + writeJsonFile(path.join(repoRoot, "package.json"), { + files: ["dist/**", "!dist/extensions/diagnostics-otel/**", "!dist/extensions/feishu/**"], + }); + writeDistPluginFile(repoRoot, "dist", "diagnostics-otel"); + writeDistPluginFile(repoRoot, "dist", "feishu"); + writeDistPluginFile(repoRoot, "dist-runtime", "feishu"); + writeDistPluginFile(repoRoot, "dist", "telegram"); + + const removed = pruneDockerPluginDist({ + repoRoot, + env: { OPENCLAW_EXTENSIONS: "diagnostics-otel" } as NodeJS.ProcessEnv, + }); + + expect(removed).toEqual(["dist/extensions/feishu", "dist-runtime/extensions/feishu"]); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "diagnostics-otel"))).toBe(true); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "feishu"))).toBe(false); + expect(fs.existsSync(path.join(repoRoot, "dist-runtime", "extensions", "feishu"))).toBe(false); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "telegram"))).toBe(true); + }); +});