Skip to content

Commit 23c5808

Browse files
authored
fix(docker): prune omitted plugin runtime deps
1 parent 205c595 commit 23c5808

5 files changed

Lines changed: 276 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121
- Infra/json: retry transient `File changed during read` races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)
2222
- Gateway/sessions: preserve compatible session auth profile overrides when switching models within the same provider, including provider-auth aliases. Fixes #81837. (#81886) Thanks @TurboTheTurtle.
2323
- Gateway/status: surface inbound delivery telemetry counters and transport-liveness warnings in `openclaw status --all`. Fixes #49577. (#72724)
24+
- Docker: prune package-excluded plugin source workspaces and dependency closures so runtime images do not keep packages for plugins that were not opted in.
2425
- Providers/Ollama: treat Docker/OrbStack host aliases as local Ollama endpoints so `ollama-local` marker auth works when OpenClaw runs inside a VM/container and Ollama runs on the host. Fixes #84875.
2526
- QA-Lab: keep explicitly searchable/deferred OpenClaw dynamic tool rows report-only by default so tool-coverage gates do not treat mock discovery gaps as hard product failures. (#80319) Thanks @100yenadmin.
2627
- Agents: cap heartbeat model bleed context hints by the stored session window when runtime model metadata is unavailable, so overflow recovery advice does not suggest a larger window than the active session actually has.

Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ ENV OPENCLAW_PREFER_PNPM=1
117117
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
118118
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
119119

120-
# Prune dev dependencies and strip build-only metadata before copying
121-
# runtime assets into the final image.
120+
# Prune dev dependencies, omitted plugin runtime packages, and build-only
121+
# metadata before copying runtime assets into the final image.
122122
FROM build AS runtime-assets
123123
ARG OPENCLAW_EXTENSIONS
124124
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
@@ -128,8 +128,8 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
128128
--config.supportedArchitectures.os=linux \
129129
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
130130
--config.supportedArchitectures.libc=glibc && \
131+
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs && \
131132
node scripts/postinstall-bundled-plugins.mjs && \
132-
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \
133133
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
134134
node scripts/check-package-dist-imports.mjs /app
135135

scripts/prune-docker-plugin-dist.mjs

Lines changed: 143 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { pathToFileURL } from "node:url";
44
import { collectRootPackageExcludedExtensionDirs } from "./lib/bundled-plugin-build-entries.mjs";
55
import { removePathIfExists } from "./runtime-postbuild-shared.mjs";
66

7+
const RUNTIME_DEPENDENCY_FIELDS = ["dependencies", "optionalDependencies"];
8+
79
function parsePluginList(value) {
810
if (typeof value !== "string") {
911
return new Set();
@@ -20,27 +22,157 @@ export function parseDockerPluginKeepList(value) {
2022
return parsePluginList(value);
2123
}
2224

25+
function readPackageJson(filePath) {
26+
if (!fs.existsSync(filePath)) {
27+
return null;
28+
}
29+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
30+
}
31+
32+
function collectRuntimeDependencyNames(packageJson) {
33+
const dependencies = new Set();
34+
for (const field of RUNTIME_DEPENDENCY_FIELDS) {
35+
for (const dependencyName of Object.keys(packageJson?.[field] ?? {})) {
36+
dependencies.add(dependencyName);
37+
}
38+
}
39+
return dependencies;
40+
}
41+
42+
function nodeModulePath(repoRoot, packageName) {
43+
return path.join(repoRoot, "node_modules", ...packageName.split("/"));
44+
}
45+
46+
function removeEmptyScopeDir(repoRoot, packageName) {
47+
if (!packageName.startsWith("@")) {
48+
return;
49+
}
50+
const [scope] = packageName.split("/");
51+
const scopeDir = path.join(repoRoot, "node_modules", scope);
52+
try {
53+
fs.rmdirSync(scopeDir);
54+
} catch {
55+
// Scope still has other packages or does not exist.
56+
}
57+
}
58+
59+
function collectPackageRuntimeClosure(repoRoot, seedPackageNames) {
60+
const seen = new Set();
61+
const stack = [...seedPackageNames];
62+
63+
while (stack.length > 0) {
64+
const packageName = stack.pop();
65+
if (!packageName || seen.has(packageName)) {
66+
continue;
67+
}
68+
seen.add(packageName);
69+
70+
const packageJson = readPackageJson(path.join(nodeModulePath(repoRoot, packageName), "package.json"));
71+
for (const dependencyName of collectRuntimeDependencyNames(packageJson)) {
72+
if (!seen.has(dependencyName)) {
73+
stack.push(dependencyName);
74+
}
75+
}
76+
}
77+
78+
return seen;
79+
}
80+
81+
function collectWorkspacePackageRuntimeSeeds(repoRoot, workspaceDir, excludedPluginIds) {
82+
const seeds = new Set();
83+
const workspaceRoot = path.join(repoRoot, workspaceDir);
84+
if (!fs.existsSync(workspaceRoot)) {
85+
return seeds;
86+
}
87+
88+
for (const entry of fs.readdirSync(workspaceRoot, { withFileTypes: true })) {
89+
if (!entry.isDirectory() || excludedPluginIds.has(entry.name)) {
90+
continue;
91+
}
92+
const packageJson = readPackageJson(path.join(workspaceRoot, entry.name, "package.json"));
93+
if (typeof packageJson?.name === "string") {
94+
seeds.add(packageJson.name);
95+
}
96+
for (const dependencyName of collectRuntimeDependencyNames(packageJson)) {
97+
seeds.add(dependencyName);
98+
}
99+
}
100+
return seeds;
101+
}
102+
103+
function pruneNodeModulesForOmittedPlugins(repoRoot, bundledPluginDir, omittedPluginIds) {
104+
const rootPackageJson = readPackageJson(path.join(repoRoot, "package.json"));
105+
const omittedPackageNames = new Set();
106+
const omittedSeeds = new Set();
107+
108+
for (const pluginId of omittedPluginIds) {
109+
const packageJson = readPackageJson(path.join(repoRoot, bundledPluginDir, pluginId, "package.json"));
110+
if (typeof packageJson?.name === "string") {
111+
omittedPackageNames.add(packageJson.name);
112+
}
113+
for (const dependencyName of collectRuntimeDependencyNames(packageJson)) {
114+
omittedSeeds.add(dependencyName);
115+
}
116+
}
117+
118+
const keptSeeds = new Set(collectRuntimeDependencyNames(rootPackageJson));
119+
for (const dependencyName of collectWorkspacePackageRuntimeSeeds(repoRoot, "packages", new Set())) {
120+
keptSeeds.add(dependencyName);
121+
}
122+
for (const dependencyName of collectWorkspacePackageRuntimeSeeds(
123+
repoRoot,
124+
bundledPluginDir,
125+
omittedPluginIds,
126+
)) {
127+
keptSeeds.add(dependencyName);
128+
}
129+
130+
const keptClosure = collectPackageRuntimeClosure(repoRoot, keptSeeds);
131+
const omittedClosure = collectPackageRuntimeClosure(repoRoot, omittedSeeds);
132+
const removed = [];
133+
const removalCandidates = new Set([...omittedPackageNames, ...omittedClosure]);
134+
135+
for (const packageName of [...removalCandidates].toSorted((left, right) =>
136+
left.localeCompare(right),
137+
)) {
138+
if (keptClosure.has(packageName)) {
139+
continue;
140+
}
141+
const packageDir = nodeModulePath(repoRoot, packageName);
142+
if (!fs.existsSync(packageDir)) {
143+
continue;
144+
}
145+
removePathIfExists(packageDir);
146+
removeEmptyScopeDir(repoRoot, packageName);
147+
removed.push(path.relative(repoRoot, packageDir).replaceAll("\\", "/"));
148+
}
149+
150+
return removed;
151+
}
152+
23153
export function pruneDockerPluginDist(params = {}) {
24154
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
25155
const env = params.env ?? process.env;
156+
const bundledPluginDir = env.OPENCLAW_BUNDLED_PLUGIN_DIR ?? "extensions";
26157
const keepPluginIds = parseDockerPluginKeepList(env.OPENCLAW_EXTENSIONS);
27158
const excludedPluginIds = collectRootPackageExcludedExtensionDirs({ cwd: repoRoot });
159+
const omittedPluginIds = new Set([...excludedPluginIds].filter((pluginId) => !keepPluginIds.has(pluginId)));
28160
const removed = [];
29161

30-
for (const pluginId of [...excludedPluginIds].toSorted((left, right) =>
31-
left.localeCompare(right),
32-
)) {
33-
if (keepPluginIds.has(pluginId)) {
34-
continue;
35-
}
162+
removed.push(...pruneNodeModulesForOmittedPlugins(repoRoot, bundledPluginDir, omittedPluginIds));
36163

37-
for (const root of ["dist", "dist-runtime"]) {
38-
const pluginDistDir = path.join(repoRoot, root, "extensions", pluginId);
39-
if (!fs.existsSync(pluginDistDir)) {
164+
for (const pluginId of [...omittedPluginIds].toSorted((left, right) => left.localeCompare(right))) {
165+
for (const pluginPath of [
166+
path.join(bundledPluginDir, pluginId),
167+
path.join("dist", "extensions", pluginId),
168+
path.join("dist-runtime", "extensions", pluginId),
169+
]) {
170+
const absolutePluginPath = path.join(repoRoot, pluginPath);
171+
if (!fs.existsSync(absolutePluginPath)) {
40172
continue;
41173
}
42-
removePathIfExists(pluginDistDir);
43-
removed.push(path.relative(repoRoot, pluginDistDir).replaceAll("\\", "/"));
174+
removePathIfExists(absolutePluginPath);
175+
removed.push(path.relative(repoRoot, absolutePluginPath).replaceAll("\\", "/"));
44176
}
45177
}
46178

src/dockerfile.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ describe("Dockerfile", () => {
162162
expect(dockerfile).toContain("pnpm_config_verify_deps_before_run=false pnpm qa:lab:build");
163163
});
164164

165-
it("prunes runtime dependencies after the build stage", async () => {
165+
it("prunes runtime dependencies and omitted plugin packages after the build stage", async () => {
166166
const dockerfile = await readFile(dockerfilePath, "utf8");
167167
expect(dockerfile).toContain("FROM build AS runtime-assets");
168168
expect(dockerfile).toContain("ARG OPENCLAW_EXTENSIONS");
@@ -180,16 +180,23 @@ describe("Dockerfile", () => {
180180
expect(dockerfile).toContain(
181181
"COPY --from=workspace-deps /out/${OPENCLAW_BUNDLED_PLUGIN_DIR}/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/",
182182
);
183+
expect(dockerfile).toContain(
184+
'OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs',
185+
);
183186
expect(dockerfile).toContain("CI=true pnpm prune --prod \\");
187+
expect(
188+
dockerfile.indexOf("CI=true pnpm prune --prod \\"),
189+
).toBeLessThan(
190+
dockerfile.indexOf(
191+
'OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs',
192+
),
193+
);
184194
expect(dockerfile).toContain("--config.offline=true");
185195
expect(dockerfile).toContain("--config.supportedArchitectures.os=linux");
186196
expect(dockerfile).toContain(
187197
"--config.supportedArchitectures.cpu=\"$(node -p 'process.arch')\"",
188198
);
189199
expect(dockerfile).toContain("--config.supportedArchitectures.libc=glibc");
190-
expect(dockerfile).toContain(
191-
'OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs',
192-
);
193200
expect(dockerfile).not.toContain("pnpm-workspace.runtime.yaml");
194201
expect(dockerfile).not.toContain("write-runtime-pnpm-workspace");
195202
expect(dockerfile).not.toContain(

src/plugins/prune-docker-plugin-dist.test.ts

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,29 @@ function writeDistPluginFile(repoRoot: string, root: "dist" | "dist-runtime", pl
1919
fs.writeFileSync(path.join(pluginDir, "openclaw.plugin.json"), "{}\n", "utf8");
2020
}
2121

22+
function writePluginSourcePackage(repoRoot: string, pluginId: string) {
23+
const pluginDir = path.join(repoRoot, "extensions", pluginId);
24+
fs.mkdirSync(pluginDir, { recursive: true });
25+
writeJsonFile(path.join(pluginDir, "package.json"), {
26+
name: `@openclaw/${pluginId}`,
27+
version: "0.0.0",
28+
});
29+
}
30+
31+
function writeNodePackage(
32+
repoRoot: string,
33+
packageName: string,
34+
packageJson: Record<string, unknown> = {},
35+
) {
36+
const packageDir = path.join(repoRoot, "node_modules", ...packageName.split("/"));
37+
fs.mkdirSync(packageDir, { recursive: true });
38+
writeJsonFile(path.join(packageDir, "package.json"), {
39+
name: packageName,
40+
version: "0.0.0",
41+
...packageJson,
42+
});
43+
}
44+
2245
afterEach(() => {
2346
cleanupTempDirs(tempDirs);
2447
});
@@ -32,11 +55,14 @@ describe("pruneDockerPluginDist", () => {
3255
]);
3356
});
3457

35-
it("removes package-excluded plugin dist unless Docker explicitly opts it in", () => {
58+
it("removes package-excluded plugin runtime artifacts unless Docker explicitly opts it in", () => {
3659
const repoRoot = makeRepoRoot("openclaw-docker-plugin-dist-");
3760
writeJsonFile(path.join(repoRoot, "package.json"), {
3861
files: ["dist/**", "!dist/extensions/diagnostics-otel/**", "!dist/extensions/feishu/**"],
3962
});
63+
writePluginSourcePackage(repoRoot, "diagnostics-otel");
64+
writePluginSourcePackage(repoRoot, "feishu");
65+
writePluginSourcePackage(repoRoot, "telegram");
4066
writeDistPluginFile(repoRoot, "dist", "diagnostics-otel");
4167
writeDistPluginFile(repoRoot, "dist", "feishu");
4268
writeDistPluginFile(repoRoot, "dist-runtime", "feishu");
@@ -47,10 +73,100 @@ describe("pruneDockerPluginDist", () => {
4773
env: { OPENCLAW_EXTENSIONS: "diagnostics-otel" } as NodeJS.ProcessEnv,
4874
});
4975

50-
expect(removed).toEqual(["dist/extensions/feishu", "dist-runtime/extensions/feishu"]);
76+
expect(removed).toEqual([
77+
"extensions/feishu",
78+
"dist/extensions/feishu",
79+
"dist-runtime/extensions/feishu",
80+
]);
81+
expect(fs.existsSync(path.join(repoRoot, "extensions", "diagnostics-otel"))).toBe(true);
82+
expect(fs.existsSync(path.join(repoRoot, "extensions", "feishu"))).toBe(false);
83+
expect(fs.existsSync(path.join(repoRoot, "extensions", "telegram"))).toBe(true);
5184
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "diagnostics-otel"))).toBe(true);
5285
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "feishu"))).toBe(false);
5386
expect(fs.existsSync(path.join(repoRoot, "dist-runtime", "extensions", "feishu"))).toBe(false);
5487
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "telegram"))).toBe(true);
5588
});
89+
90+
it("honors custom bundled plugin source roots when pruning Docker runtime importers", () => {
91+
const repoRoot = makeRepoRoot("openclaw-docker-plugin-source-");
92+
writeJsonFile(path.join(repoRoot, "package.json"), {
93+
files: ["dist/**", "!dist/extensions/acpx/**"],
94+
});
95+
const pluginDir = path.join(repoRoot, "plugins", "acpx");
96+
fs.mkdirSync(pluginDir, { recursive: true });
97+
writeJsonFile(path.join(pluginDir, "package.json"), {
98+
name: "@openclaw/acpx",
99+
version: "0.0.0",
100+
});
101+
102+
const removed = pruneDockerPluginDist({
103+
repoRoot,
104+
env: {
105+
OPENCLAW_BUNDLED_PLUGIN_DIR: "plugins",
106+
} as NodeJS.ProcessEnv,
107+
});
108+
109+
expect(removed).toEqual(["plugins/acpx"]);
110+
expect(fs.existsSync(pluginDir)).toBe(false);
111+
});
112+
113+
it("removes node_modules dependency closure that only omitted Docker plugins need", () => {
114+
const repoRoot = makeRepoRoot("openclaw-docker-plugin-node-modules-");
115+
writeJsonFile(path.join(repoRoot, "package.json"), {
116+
files: ["dist/**", "!dist/extensions/acpx/**", "!dist/extensions/codex/**"],
117+
dependencies: {
118+
zod: "0.0.0",
119+
},
120+
});
121+
writeJsonFile(path.join(repoRoot, "extensions", "acpx", "package.json"), {
122+
name: "@openclaw/acpx",
123+
version: "0.0.0",
124+
dependencies: {
125+
"@zed-industries/codex-acp": "0.0.0",
126+
zod: "0.0.0",
127+
},
128+
});
129+
writeJsonFile(path.join(repoRoot, "extensions", "codex", "package.json"), {
130+
name: "@openclaw/codex",
131+
version: "0.0.0",
132+
dependencies: {
133+
"@openai/codex": "0.0.0",
134+
zod: "0.0.0",
135+
},
136+
});
137+
writeNodePackage(repoRoot, "@openclaw/acpx");
138+
writeNodePackage(repoRoot, "@openclaw/codex");
139+
writeNodePackage(repoRoot, "zod");
140+
writeNodePackage(repoRoot, "@openai/codex", {
141+
optionalDependencies: {
142+
"@openai/codex-linux-x64": "0.0.0",
143+
},
144+
});
145+
writeNodePackage(repoRoot, "@openai/codex-linux-x64");
146+
writeNodePackage(repoRoot, "@zed-industries/codex-acp", {
147+
optionalDependencies: {
148+
"@zed-industries/codex-acp-linux-x64": "0.0.0",
149+
},
150+
});
151+
writeNodePackage(repoRoot, "@zed-industries/codex-acp-linux-x64");
152+
153+
const removed = pruneDockerPluginDist({
154+
repoRoot,
155+
env: { OPENCLAW_EXTENSIONS: "codex" } as NodeJS.ProcessEnv,
156+
});
157+
158+
expect(removed).toEqual([
159+
"node_modules/@openclaw/acpx",
160+
"node_modules/@zed-industries/codex-acp",
161+
"node_modules/@zed-industries/codex-acp-linux-x64",
162+
"extensions/acpx",
163+
]);
164+
expect(fs.existsSync(path.join(repoRoot, "node_modules", "zod"))).toBe(true);
165+
expect(fs.existsSync(path.join(repoRoot, "node_modules", "@openai", "codex"))).toBe(true);
166+
expect(fs.existsSync(path.join(repoRoot, "node_modules", "@openai", "codex-linux-x64"))).toBe(
167+
true,
168+
);
169+
expect(fs.existsSync(path.join(repoRoot, "node_modules", "@zed-industries"))).toBe(false);
170+
expect(fs.existsSync(path.join(repoRoot, "extensions", "codex"))).toBe(true);
171+
});
56172
});

0 commit comments

Comments
 (0)