From 3f3f66a5f75bffa86b3ff0c42ebf914f4a089dd6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 8 Mar 2026 16:07:04 -0700 Subject: [PATCH] Docker: trim runtime image payload (#40307) * Docker: shrink runtime image payload * Docker: add runtime pnpm opt-in * Docker: collapse helper entrypoint chmod layers * Docker: restore bundled pnpm runtime * Update CHANGELOG.md --- CHANGELOG.md | 2 +- Dockerfile | 53 ++++++++++++-------- Dockerfile.sandbox-browser | 3 +- package.json | 1 + scripts/docker/cleanup-smoke/Dockerfile | 3 +- scripts/docker/install-sh-e2e/Dockerfile | 3 +- scripts/docker/install-sh-nonroot/Dockerfile | 3 +- scripts/docker/install-sh-smoke/Dockerfile | 3 +- src/docker-setup.e2e.test.ts | 2 +- src/dockerfile.test.ts | 17 +++++++ 10 files changed, 56 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4b50a361e..c1463d7bc16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai ### Breaking ### Fixes +- Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc. ## 2026.3.8 @@ -46,7 +47,6 @@ Docs: https://docs.openclaw.ai - Context engine registry/bundled builds: share the registry state through a `globalThis` singleton so duplicated bundled module copies can resolve engines registered by each other at runtime, with regression coverage for duplicate-module imports. (#40115) thanks @jalehman. - macOS/Tailscale gateway discovery: keep Tailscale Serve probing alive when other remote gateways are already discovered, prefer direct transport for resolved `.ts.net` and Tailscale Serve gateways, and set `TERM=dumb` for GUI-launched Tailscale CLI discovery. (#40167) thanks @ngutman. - Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk. -- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs. ## 2026.3.7 diff --git a/Dockerfile b/Dockerfile index 6b147441e5e..f1d7163d192 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,15 @@ RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile COPY . . +# Normalize extension paths now so runtime COPY preserves safe modes +# without adding a second full extensions layer. +RUN for dir in /app/extensions /app/.agent /app/.agents; do \ + if [ -d "$dir" ]; then \ + find "$dir" -type d -exec chmod 755 {} +; \ + find "$dir" -type f -exec chmod 644 {} +; \ + fi; \ + done + # A2UI bundle may fail under QEMU cross-compilation (e.g. building amd64 # on Apple Silicon). CI builds natively per-arch so this is a no-op there. # Stub it so local cross-arch builds still succeed. @@ -67,11 +76,17 @@ RUN pnpm canvas:a2ui:bundle || \ echo "/* A2UI bundle unavailable in this build */" > src/canvas-host/a2ui/a2ui.bundle.js && \ echo "stub" > src/canvas-host/a2ui/.bundle.hash && \ rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI) -RUN pnpm build +RUN pnpm build:docker # Force pnpm for UI build (Bun may fail on ARM/Synology architectures) ENV OPENCLAW_PREFER_PNPM=1 RUN pnpm ui:build +# Prune dev dependencies and strip build-only metadata before copying +# runtime assets into the final image. +FROM build AS runtime-assets +RUN CI=true pnpm prune --prod && \ + find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete + # ── Runtime base images ───────────────────────────────────────── FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default ARG OPENCLAW_NODE_BOOKWORM_DIGEST @@ -110,19 +125,22 @@ RUN apt-get update && \ RUN chown node:node /app -COPY --from=build --chown=node:node /app/dist ./dist -COPY --from=build --chown=node:node /app/node_modules ./node_modules -COPY --from=build --chown=node:node /app/package.json . -COPY --from=build --chown=node:node /app/openclaw.mjs . -COPY --from=build --chown=node:node /app/extensions ./extensions -COPY --from=build --chown=node:node /app/skills ./skills -COPY --from=build --chown=node:node /app/docs ./docs +COPY --from=runtime-assets --chown=node:node /app/dist ./dist +COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules +COPY --from=runtime-assets --chown=node:node /app/package.json . +COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs . +COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions +COPY --from=runtime-assets --chown=node:node /app/skills ./skills +COPY --from=runtime-assets --chown=node:node /app/docs ./docs -# Docker live-test runners invoke `pnpm` inside the runtime image. -# Activate the exact pinned package manager now so the container does not -# rely on a first-run network fetch or missing shims under the non-root user. -RUN corepack enable && \ - corepack prepare "$(node -p "require('./package.json').packageManager")" --activate +# Keep pnpm available in the runtime image for container-local workflows. +# Use a shared Corepack home so the non-root `node` user does not need a +# first-run network fetch when invoking pnpm. +ENV COREPACK_HOME=/usr/local/share/corepack +RUN install -d -m 0755 "$COREPACK_HOME" && \ + corepack enable && \ + corepack prepare "$(node -p "require('./package.json').packageManager")" --activate && \ + chmod -R a+rX "$COREPACK_HOME" # Install additional system packages needed by your skills or extensions. # Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" . @@ -182,15 +200,6 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ fi -# Normalize extension paths so plugin safety checks do not reject -# world-writable directories inherited from source file modes. -RUN for dir in /app/extensions /app/.agent /app/.agents; do \ - if [ -d "$dir" ]; then \ - find "$dir" -type d -exec chmod 755 {} +; \ - find "$dir" -type f -exec chmod 644 {} +; \ - fi; \ - done - # Expose the CLI binary without requiring npm global writes as non-root. RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \ && chmod 755 /app/openclaw.mjs diff --git a/Dockerfile.sandbox-browser b/Dockerfile.sandbox-browser index ec9faf71113..78b0de98904 100644 --- a/Dockerfile.sandbox-browser +++ b/Dockerfile.sandbox-browser @@ -20,8 +20,7 @@ RUN apt-get update \ xvfb \ && rm -rf /var/lib/apt/lists/* -COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser -RUN chmod +x /usr/local/bin/openclaw-sandbox-browser +COPY --chmod=755 scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser RUN useradd --create-home --shell /bin/bash sandbox USER sandbox diff --git a/package.json b/package.json index 93692b174ae..753fe15a059 100644 --- a/package.json +++ b/package.json @@ -224,6 +224,7 @@ "android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest", "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile index 1d9288b0df5..34ce3327ac7 100644 --- a/scripts/docker/cleanup-smoke/Dockerfile +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -13,7 +13,6 @@ RUN corepack enable \ && pnpm install --frozen-lockfile COPY . . -COPY scripts/docker/cleanup-smoke/run.sh /usr/local/bin/openclaw-cleanup-smoke -RUN chmod +x /usr/local/bin/openclaw-cleanup-smoke +COPY --chmod=755 scripts/docker/cleanup-smoke/run.sh /usr/local/bin/openclaw-cleanup-smoke ENTRYPOINT ["/usr/local/bin/openclaw-cleanup-smoke"] diff --git a/scripts/docker/install-sh-e2e/Dockerfile b/scripts/docker/install-sh-e2e/Dockerfile index 26b69b0b7ef..839d637a04b 100644 --- a/scripts/docker/install-sh-e2e/Dockerfile +++ b/scripts/docker/install-sh-e2e/Dockerfile @@ -9,8 +9,7 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh -COPY run.sh /usr/local/bin/openclaw-install-e2e -RUN chmod +x /usr/local/bin/openclaw-install-e2e +COPY --chmod=755 run.sh /usr/local/bin/openclaw-install-e2e RUN useradd --create-home --shell /bin/bash appuser USER appuser diff --git a/scripts/docker/install-sh-nonroot/Dockerfile b/scripts/docker/install-sh-nonroot/Dockerfile index 5543ef84882..9b7912323a4 100644 --- a/scripts/docker/install-sh-nonroot/Dockerfile +++ b/scripts/docker/install-sh-nonroot/Dockerfile @@ -28,7 +28,6 @@ ENV NPM_CONFIG_AUDIT=false COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh -COPY install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot -RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot +COPY --chmod=755 install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot ENTRYPOINT ["/usr/local/bin/openclaw-install-nonroot"] diff --git a/scripts/docker/install-sh-smoke/Dockerfile b/scripts/docker/install-sh-smoke/Dockerfile index ee3221607fb..eb2dcfe5226 100644 --- a/scripts/docker/install-sh-smoke/Dockerfile +++ b/scripts/docker/install-sh-smoke/Dockerfile @@ -20,7 +20,6 @@ RUN set -eux; \ COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh -COPY install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke -RUN chmod +x /usr/local/bin/openclaw-install-smoke +COPY --chmod=755 install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke ENTRYPOINT ["/usr/local/bin/openclaw-install-smoke"] diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 6fd42e256ed..6890e7d55a8 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -163,7 +163,7 @@ describe("docker-setup.sh", () => { sandbox = null; }); - it("handles env defaults, home-volume mounts, and apt build args", async () => { + it("handles env defaults, home-volume mounts, and Docker build args", async () => { const activeSandbox = requireSandbox(sandbox); const result = runDockerSetup(activeSandbox, { diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 4dd41398e5a..a23b7e8e083 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -37,6 +37,15 @@ describe("Dockerfile", () => { expect(dockerfile).toContain("apt-get install -y --no-install-recommends xvfb"); }); + it("prunes runtime dependencies after the build stage", async () => { + const dockerfile = await readFile(dockerfilePath, "utf8"); + expect(dockerfile).toContain("FROM build AS runtime-assets"); + expect(dockerfile).toContain("CI=true pnpm prune --prod"); + expect(dockerfile).toContain( + "COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules", + ); + }); + it("normalizes plugin and agent paths permissions in image layers", async () => { const dockerfile = await readFile(dockerfilePath, "utf8"); expect(dockerfile).toContain("for dir in /app/extensions /app/.agent /app/.agents"); @@ -49,4 +58,12 @@ describe("Dockerfile", () => { expect(dockerfile).toContain('== "fpr" {'); expect(dockerfile).not.toContain('\\"fpr\\"'); }); + + it("keeps runtime pnpm available", async () => { + const dockerfile = await readFile(dockerfilePath, "utf8"); + expect(dockerfile).toContain("ENV COREPACK_HOME=/usr/local/share/corepack"); + expect(dockerfile).toContain( + 'corepack prepare "$(node -p "require(\'./package.json\').packageManager")" --activate', + ); + }); });