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,4 +1,5 @@
# Opt-in plugin dependencies at build time (space- or comma-separated directory names).
# Opt-in plugin dependencies and supported runtime builds (space- or comma-separated ids).
# Manifest ids and existing source-directory names are accepted.
# Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,matrix" .
#
# Multi-stage build produces a minimal runtime image without build tools,
@@ -30,8 +31,10 @@ FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS workspace-deps
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
# Copy package.json files for workspace packages used by the install layer.
# Manifest-only bundled plugins remain valid selections but need no workspace metadata.
RUN --mount=type=bind,source=packages,target=/tmp/packages,readonly \
--mount=type=bind,source=${OPENCLAW_BUNDLED_PLUGIN_DIR},target=/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR},readonly \
--mount=type=bind,source=scripts/lib/docker-plugin-selection.mjs,target=/tmp/docker-plugin-selection.mjs,readonly \
mkdir -p /out/packages "/out/${OPENCLAW_BUNDLED_PLUGIN_DIR}" && \
for manifest in /tmp/packages/*/package.json; do \
[ -f "$manifest" ] || continue; \
@@ -40,18 +43,20 @@ RUN --mount=type=bind,source=packages,target=/tmp/packages,readonly \
mkdir -p "/out/packages/$pkg_name" && \
cp "$manifest" "/out/packages/$pkg_name/package.json"; \
done && \
for ext in $(printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' '); do \
if [ -f "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" ]; then \
node /tmp/docker-plugin-selection.mjs "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}" "$OPENCLAW_EXTENSIONS" \
> /out/openclaw-selected-plugin-dirs && \
while IFS= read -r ext; do \
ext_dir="/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext"; \
if [ -f "$ext_dir/package.json" ]; then \
mkdir -p "/out/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext" && \
cp "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" "/out/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json"; \
cp "$ext_dir/package.json" "/out/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json"; \
fi; \
done
done < /out/openclaw-selected-plugin-dirs
# ── Stage 2: Build ──────────────────────────────────────────────
FROM ${OPENCLAW_BUN_IMAGE} AS bun-binary
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_DOCKER_BUILD_NODE_OPTIONS
ARG OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB
ARG OPENCLAW_DOCKER_BUILD_SKIP_DTS
@@ -72,6 +77,7 @@ COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs
COPY --from=workspace-deps /out/packages/ ./packages/
COPY --from=workspace-deps /out/${OPENCLAW_BUNDLED_PLUGIN_DIR}/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/
COPY --from=workspace-deps /out/openclaw-selected-plugin-dirs /tmp/openclaw-selected-plugin-dirs
# Reduce OOM risk on low-memory hosts during dependency installation.
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
@@ -86,7 +92,7 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
# still exiting successfully, so retry the package downloader before failing.
# Skip the entire check when matrix is not a bundled extension (e.g. msteams-only builds).
RUN set -eux; \
if ! printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'matrix'; then \
if ! grep -qx 'matrix' /tmp/openclaw-selected-plugin-dirs; then \
echo "==> matrix not bundled, skipping matrix-sdk-crypto check"; \
exit 0; \
fi; \
@@ -132,16 +138,17 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN set -eu; \
selected_plugin_dirs="$(cat /tmp/openclaw-selected-plugin-dirs)"; \
if [ -z "$OPENCLAW_BUILD_TIMESTAMP" ]; then \
OPENCLAW_BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"; \
export OPENCLAW_BUILD_TIMESTAMP; \
fi; \
if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
if grep -qx 'qa-lab' /tmp/openclaw-selected-plugin-dirs; then \
export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \
fi; \
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD="$OPENCLAW_DOCKER_BUILD_SKIP_DTS" OPENCLAW_TSDOWN_MAX_OLD_SPACE_MB="$OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB" NODE_OPTIONS="$OPENCLAW_DOCKER_BUILD_NODE_OPTIONS" pnpm_config_verify_deps_before_run=false pnpm build:docker; \
OPENCLAW_INTERNAL_DOCKER_BUILD_PLUGIN_IDS="$selected_plugin_dirs" OPENCLAW_RUN_NODE_SKIP_DTS_BUILD="$OPENCLAW_DOCKER_BUILD_SKIP_DTS" OPENCLAW_TSDOWN_MAX_OLD_SPACE_MB="$OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB" NODE_OPTIONS="$OPENCLAW_DOCKER_BUILD_NODE_OPTIONS" pnpm_config_verify_deps_before_run=false pnpm build:docker; \
pnpm_config_verify_deps_before_run=false pnpm ui:build
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
RUN if grep -qx 'qa-lab' /tmp/openclaw-selected-plugin-dirs; then \
pnpm_config_verify_deps_before_run=false pnpm qa:lab:build && \
mkdir -p dist/extensions/qa-lab/web && \
rm -rf dist/extensions/qa-lab/web/dist && \
@@ -151,7 +158,6 @@ RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -q
# Prune dev dependencies, omitted plugin runtime packages, and build-only
# metadata before copying runtime assets into the final image.
FROM build AS runtime-assets
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
# BuildKit cache mounts are not part of cached layers; seed tarballs for the
# installed prod graph in the same step that runs offline prune.
@@ -162,7 +168,7 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
--config.supportedArchitectures.os=linux \
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
--config.supportedArchitectures.libc=glibc && \
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs && \
OPENCLAW_EXTENSIONS="$(cat /tmp/openclaw-selected-plugin-dirs)" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs && \
node scripts/postinstall-bundled-plugins.mjs && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
if [ -L /app/node_modules/@openclaw/ai ]; then \

View File

@@ -4216,6 +4216,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: Manual flow
- H3: Upgrading container images
- H3: Environment variables
- H3: Source-built images with selected plugins
- H3: Observability
- H3: Health checks
- H3: LAN vs loopback

View File

@@ -151,29 +151,29 @@ same PVC, then restart the Deployment or StatefulSet.
Optional variables accepted by `scripts/docker/setup.sh` (and, for the gateway container, by `docker-compose.yml` directly):
| Variable | Purpose |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `OPENCLAW_IMAGE` | Use a remote image instead of building locally |
| `OPENCLAW_IMAGE_APT_PACKAGES` | Install extra apt packages during build (space-separated). Legacy alias: `OPENCLAW_DOCKER_APT_PACKAGES` |
| `OPENCLAW_IMAGE_PIP_PACKAGES` | Install extra Python packages during build (space-separated) |
| `OPENCLAW_EXTENSIONS` | Pre-install plugin dependencies at build time (comma- or space-separated ids) |
| `OPENCLAW_DOCKER_BUILD_NODE_OPTIONS` | Override the local source-build Node options (default `--max-old-space-size=8192`) |
| `OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB` | Override the local source-build tsdown heap in MB |
| `OPENCLAW_DOCKER_BUILD_SKIP_DTS` | Skip declaration output during runtime-only local image builds (default `1`) |
| `OPENCLAW_INSTALL_BROWSER` | Bake Chromium + Xvfb into the image at build time |
| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) |
| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume |
| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) |
| `OPENCLAW_SKIP_ONBOARDING` | Skip the interactive onboarding step (`1`, `true`, `yes`, `on`) |
| `OPENCLAW_DOCKER_SOCKET` | Override the Docker socket path |
| `OPENCLAW_DISABLE_BONJOUR` | Force Bonjour/mDNS advertising on (`0`) or off (`1`); see [Bonjour / mDNS](#bonjour--mdns) |
| `OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS` | Disable bundled plugin source bind-mount overlays |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Shared OTLP/HTTP collector endpoint for OpenTelemetry export |
| `OTEL_EXPORTER_OTLP_*_ENDPOINT` | Signal-specific OTLP endpoints for traces, metrics, or logs |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol override. Only `http/protobuf` is supported today |
| `OTEL_SERVICE_NAME` | Service name used for OpenTelemetry resources |
| `OTEL_SEMCONV_STABILITY_OPT_IN` | Opt in to latest experimental GenAI semantic attributes |
| `OPENCLAW_OTEL_PRELOADED` | Skip starting a second OpenTelemetry SDK when one is preloaded |
| Variable | Purpose |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `OPENCLAW_IMAGE` | Use a remote image instead of building locally |
| `OPENCLAW_IMAGE_APT_PACKAGES` | Install extra apt packages during build (space-separated). Legacy alias: `OPENCLAW_DOCKER_APT_PACKAGES` |
| `OPENCLAW_IMAGE_PIP_PACKAGES` | Install extra Python packages during build (space-separated) |
| `OPENCLAW_EXTENSIONS` | Compile/package supported selected plugins and install their runtime dependencies (comma- or space-separated ids) |
| `OPENCLAW_DOCKER_BUILD_NODE_OPTIONS` | Override the local source-build Node options (default `--max-old-space-size=8192`) |
| `OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB` | Override the local source-build tsdown heap in MB |
| `OPENCLAW_DOCKER_BUILD_SKIP_DTS` | Skip declaration output during runtime-only local image builds (default `1`) |
| `OPENCLAW_INSTALL_BROWSER` | Bake Chromium + Xvfb into the image at build time |
| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) |
| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume |
| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) |
| `OPENCLAW_SKIP_ONBOARDING` | Skip the interactive onboarding step (`1`, `true`, `yes`, `on`) |
| `OPENCLAW_DOCKER_SOCKET` | Override the Docker socket path |
| `OPENCLAW_DISABLE_BONJOUR` | Force Bonjour/mDNS advertising on (`0`) or off (`1`); see [Bonjour / mDNS](#bonjour--mdns) |
| `OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS` | Disable bundled plugin source bind-mount overlays |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Shared OTLP/HTTP collector endpoint for OpenTelemetry export |
| `OTEL_EXPORTER_OTLP_*_ENDPOINT` | Signal-specific OTLP endpoints for traces, metrics, or logs |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol override. Only `http/protobuf` is supported today |
| `OTEL_SERVICE_NAME` | Service name used for OpenTelemetry resources |
| `OTEL_SEMCONV_STABILITY_OPT_IN` | Opt in to latest experimental GenAI semantic attributes |
| `OPENCLAW_OTEL_PRELOADED` | Skip starting a second OpenTelemetry SDK when one is preloaded |
The official image ships no Homebrew. During onboarding, OpenClaw hides brew-only skill dependency installers in a Linux container without `brew`; provide those dependencies through a custom image or install manually. Use `OPENCLAW_IMAGE_APT_PACKAGES` for Debian-packaged dependencies and `OPENCLAW_IMAGE_PIP_PACKAGES` for Python dependencies (runs `python3 -m pip install --break-system-packages` at build time, so pin versions and use only indexes you trust).
@@ -183,6 +183,70 @@ If Docker reports `ResourceExhausted`, `cannot allocate memory`, or aborts durin
OPENCLAW_DOCKER_BUILD_NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB=4096
```
### Source-built images with selected plugins
`OPENCLAW_EXTENSIONS` selects plugin manifest ids from the source checkout;
existing source-directory names are also accepted when they differ. The Docker
build resolves the selection to source directories once, installs production
dependencies, and, when a selected plugin is published separately with
`openclaw.build.bundledDist: false`, compiles its runtime into the root bundled
dist. This Docker-only packaging does not change the plugin's npm or ClawHub
artifact contract. Unknown, invalid, or ambiguous ids fail the image build.
Known dependency/source-only ids keep their existing source and dependency
staging without gaining a compiled root dist entry. A selected plugin with
unified build entries must compile successfully; unselected external plugin
source and runtime output are pruned.
For example, these commands build separate, multi-architecture standalone
FakeCo gateway images for ClickClack, Slack, and Microsoft Teams. ClawRouter is
already part of the root OpenClaw runtime, so the ClickClack image selects only
`clickclack`. The explicit empty browser argument keeps the default image free
of Chromium:
```bash
SOURCE_SHA="$(git rev-parse HEAD)"
BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
REGISTRY="registry.example.com/fakeco"
build_gateway_image() {
gateway="$1"
selected_plugin="$2"
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg "GIT_COMMIT=${SOURCE_SHA}" \
--build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}" \
--build-arg "OPENCLAW_EXTENSIONS=${selected_plugin}" \
--build-arg OPENCLAW_INSTALL_BROWSER= \
--provenance=mode=max \
--sbom=true \
--tag "${REGISTRY}/openclaw-${gateway}:${SOURCE_SHA}" \
--push \
.
}
build_gateway_image clickclack clickclack
build_gateway_image slack slack
build_gateway_image teams msteams
```
Use `--platform linux/arm64 --load` or `--platform linux/amd64 --load` for a
single native local build. Multi-platform output and attached SBOM/provenance
require a registry or another Buildx output that preserves attestations. After
pushing, inspect the manifest and deploy the immutable digest rather than the
mutable source-SHA tag:
```bash
docker buildx imagetools inspect \
"${REGISTRY}/openclaw-clickclack:${SOURCE_SHA}"
# Deploy: registry.example.com/fakeco/openclaw-clickclack@sha256:<manifest-digest>
```
These images are for standalone OCI-based gateways and generic Docker users.
Crabhelm-managed gateways do not consume them: that delivery path builds a
separate x86_64 appliance archive containing an OpenClaw npm tarball and pins
the Node, archive, and manifest digests. Build that appliance independently
from the same landed OpenClaw source.
To test bundled plugin source against a packaged image, mount one plugin source directory over its packaged source path, e.g. `OPENCLAW_EXTRA_MOUNTS=/path/to/fork/extensions/synology-chat:/app/extensions/synology-chat:ro`. That overrides the matching compiled `/app/dist/extensions/synology-chat` bundle for the same plugin id.
### Observability

View File

@@ -36,7 +36,7 @@ The model:
| `OPENCLAW_IMAGE` / `OPENCLAW_PODMAN_IMAGE` | Use an existing/pulled image instead of building `openclaw:local` |
| `OPENCLAW_IMAGE_APT_PACKAGES` | Install extra apt packages during image build (also accepts legacy `OPENCLAW_DOCKER_APT_PACKAGES`) |
| `OPENCLAW_IMAGE_PIP_PACKAGES` | Install extra Python packages during image build; pin versions and use only package indexes you trust |
| `OPENCLAW_EXTENSIONS` | Pre-install plugin dependencies at build time |
| `OPENCLAW_EXTENSIONS` | Compile/package supported selected plugins and install their runtime dependencies |
| `OPENCLAW_INSTALL_BROWSER` | Pre-install Chromium and Xvfb for browser automation (set to `1`) |
For Quadlet-managed setup instead (Linux + systemd user services only):

View File

@@ -125,6 +125,13 @@ credential, update the external Secret that supplies `CLAWROUTER_API_KEY` and
restart the gateway workload so the new process environment is loaded. The
config file and model reference do not change.
For a source-built standalone Docker gateway, ClawRouter is already included in
the root runtime. Select only the channel plugin that needs separate packaging,
such as `OPENCLAW_EXTENSIONS=clickclack`, `slack`, or `msteams`; see
[source-built images with selected plugins](/install/docker#source-built-images-with-selected-plugins).
Archive/appliance deployments must package the same landed source through their
own artifact pipeline rather than consuming the OCI image.
## Readiness and live proof
These checks prove different boundaries; do not substitute one for another:

View File

@@ -1861,6 +1861,7 @@
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
"test:docker:rerun": "node scripts/docker-e2e-rerun.mjs",
"test:docker:root-managed-vps-upgrade": "env OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS=1 OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@2026.5.7} OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT=${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1500s} bash scripts/e2e/upgrade-survivor-docker.sh",
"test:docker:selected-plugins": "bash scripts/e2e/docker-selected-plugins.sh",
"test:docker:session-runtime-context": "bash scripts/e2e/session-runtime-context-docker.sh",
"test:docker:skill-install": "bash scripts/e2e/skill-install-docker.sh",
"test:docker:timings": "node scripts/docker-e2e-timings.mjs",

View File

@@ -26,7 +26,10 @@ const livePackageBackedLanes = new Set([
]);
// These lanes intentionally build a focused source-checkout image instead of
// consuming the shared package E2E images.
const sourceCheckoutImageLanes = new Set(["plugin-binding-command-escape"]);
const sourceCheckoutImageLanes = new Set([
"docker-selected-plugins",
"plugin-binding-command-escape",
]);
function readText(relativePath) {
return fs.readFileSync(path.join(ROOT_DIR, relativePath), "utf8");

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-build.sh"
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
IMAGE_NAME="${OPENCLAW_DOCKER_SELECTED_PLUGINS_E2E_IMAGE:-openclaw-docker-selected-plugins-e2e:local}"
DEPENDENCY_ONLY_IMAGE="${IMAGE_NAME}-dependency-only"
CONTAINER_NAME="openclaw-docker-selected-plugins-e2e-$$"
SELECTED_PLUGINS="${OPENCLAW_DOCKER_SELECTED_PLUGINS:-slack,msteams clickclack,slack}"
BUILD_GIT_COMMIT="${OPENCLAW_DOCKER_SELECTED_PLUGINS_E2E_GIT_COMMIT:-0123456789abcdef0123456789abcdef01234567}"
BUILD_TIMESTAMP="${OPENCLAW_DOCKER_SELECTED_PLUGINS_E2E_BUILD_TIMESTAMP:-2026-07-10T12:34:56.000Z}"
UNKNOWN_LOG="$(mktemp -t openclaw-docker-selected-plugins-unknown.XXXXXX)"
RUN_LOG="$(mktemp -t openclaw-docker-selected-plugins-run.XXXXXX)"
DOCKER_COMMAND_TIMEOUT="${OPENCLAW_DOCKER_SELECTED_PLUGINS_RUN_TIMEOUT:-900s}"
DEPENDENCY_ONLY_IMAGE_BUILT=0
cleanup() {
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
if [ "$DEPENDENCY_ONLY_IMAGE_BUILT" = "1" ]; then
docker_e2e_docker_cmd image rm -f "$DEPENDENCY_ONLY_IMAGE" >/dev/null 2>&1 || true
fi
rm -f "$UNKNOWN_LOG" "$RUN_LOG"
}
trap cleanup EXIT
if [ "${OPENCLAW_SKIP_DOCKER_BUILD:-0}" = "1" ]; then
echo "Reusing selected-plugin image: $IMAGE_NAME"
docker_e2e_docker_cmd image inspect "$IMAGE_NAME" >/dev/null
else
echo "Proving unknown selected plugins fail closed..."
set +e
docker_e2e_timeout_cmd "${OPENCLAW_DOCKER_SELECTED_PLUGINS_BUILD_TIMEOUT:-3600s}" \
env DOCKER_BUILDKIT=1 docker build \
--target workspace-deps \
--build-arg OPENCLAW_EXTENSIONS=missing-plugin \
-f "$ROOT_DIR/Dockerfile" \
"$ROOT_DIR" >"$UNKNOWN_LOG" 2>&1
unknown_status=$?
set -e
if [ "$unknown_status" -eq 0 ] || ! grep -Fq \
"unknown OPENCLAW_EXTENSIONS plugin id: missing-plugin" "$UNKNOWN_LOG"; then
echo "Unknown selected-plugin build did not fail closed as expected" >&2
docker_e2e_print_log "$UNKNOWN_LOG"
exit 1
fi
echo "Proving manifest ids and known dependency-only plugins remain stageable..."
docker_build_run docker-selected-plugins-dependency-only \
--target workspace-deps \
--build-arg OPENCLAW_EXTENSIONS=whatsapp,qqbot,kimi \
-t "$DEPENDENCY_ONLY_IMAGE" \
-f "$ROOT_DIR/Dockerfile" \
"$ROOT_DIR"
DEPENDENCY_ONLY_IMAGE_BUILT=1
docker_e2e_docker_run_cmd run --rm \
--entrypoint sh \
"$DEPENDENCY_ONLY_IMAGE" \
-c 'test -f /out/extensions/whatsapp/package.json && test -f /out/extensions/qqbot/package.json && test -f /out/extensions/kimi-coding/package.json && grep -qx kimi-coding /out/openclaw-selected-plugin-dirs'
echo "Building selected-plugin runtime image: $IMAGE_NAME"
docker_build_run docker-selected-plugins-build \
--build-arg "GIT_COMMIT=$BUILD_GIT_COMMIT" \
--build-arg "OPENCLAW_BUILD_TIMESTAMP=$BUILD_TIMESTAMP" \
--build-arg "OPENCLAW_EXTENSIONS=$SELECTED_PLUGINS" \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/Dockerfile" \
"$ROOT_DIR"
fi
echo "Inspecting selected plugins from the final runtime image..."
if ! docker_e2e_docker_run_cmd run --rm \
--name "$CONTAINER_NAME" \
--entrypoint bash \
-e "OPENCLAW_E2E_EXPECTED_GIT_COMMIT=$BUILD_GIT_COMMIT" \
-e "OPENCLAW_E2E_EXPECTED_BUILD_TIMESTAMP=$BUILD_TIMESTAMP" \
-v "$ROOT_DIR/scripts/e2e/lib/docker-selected-plugins:/openclaw-e2e:ro" \
"$IMAGE_NAME" \
/openclaw-e2e/scenario.sh >"$RUN_LOG" 2>&1; then
echo "Selected-plugin Docker E2E failed" >&2
docker_e2e_print_log "$RUN_LOG"
exit 1
fi
docker_e2e_print_log "$RUN_LOG"
echo "Selected-plugin Docker E2E passed"

View File

@@ -0,0 +1,118 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function assertFile(filePath) {
assert(fs.statSync(filePath).isFile(), `expected file: ${filePath}`);
}
function assertAbsent(filePath) {
assert(!fs.existsSync(filePath), `expected path to be absent: ${filePath}`);
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
const selected = {
clickclack: {
entries: ["index.js"],
capability: "channel",
},
slack: {
entries: ["index.js", "setup-entry.js"],
capability: "channel",
},
msteams: {
entries: ["index.js", "setup-entry.js"],
capability: "channel",
},
clawrouter: {
entries: ["index.js"],
capability: "text-inference",
},
};
const buildInfo = readJson("/app/dist/build-info.json");
const expectedCommit = process.env.OPENCLAW_E2E_EXPECTED_GIT_COMMIT?.toLowerCase();
const expectedBuiltAt = new Date(
process.env.OPENCLAW_E2E_EXPECTED_BUILD_TIMESTAMP ?? "",
).toISOString();
assert(buildInfo.commit === expectedCommit, `unexpected build commit: ${buildInfo.commit}`);
assert(buildInfo.builtAt === expectedBuiltAt, `unexpected build timestamp: ${buildInfo.builtAt}`);
for (const [pluginId, expected] of Object.entries(selected)) {
const pluginRoot = path.join("/app/dist/extensions", pluginId);
for (const entry of expected.entries) {
assertFile(path.join(pluginRoot, entry));
}
assertFile(path.join(pluginRoot, "openclaw.plugin.json"));
assertFile(path.join(pluginRoot, "package.json"));
const manifest = readJson(path.join(pluginRoot, "openclaw.plugin.json"));
const packageJson = readJson(path.join(pluginRoot, "package.json"));
assert(manifest.id === pluginId, `unexpected ${pluginId} manifest id: ${manifest.id}`);
assert(
packageJson.openclaw?.extensions?.includes("./index.js"),
`${pluginId} package entry was not rewritten to ./index.js`,
);
if (expected.entries.includes("setup-entry.js")) {
assert(
packageJson.openclaw?.setupEntry === "./setup-entry.js",
`${pluginId} setup entry was not rewritten to ./setup-entry.js`,
);
}
const inspect = readJson(`/tmp/openclaw-${pluginId}-inspect.json`);
assert(inspect.plugin?.id === pluginId, `unexpected ${pluginId} inspect id`);
assert(inspect.plugin?.status === "loaded", `${pluginId} runtime did not load`);
assert(inspect.plugin?.origin === "bundled", `${pluginId} did not load from bundled dist`);
assert(
inspect.capabilities?.some(
(entry) => entry?.kind === expected.capability && entry.ids?.includes(pluginId),
),
`${pluginId} did not register ${expected.capability} capability`,
);
}
for (const pluginId of ["clickclack", "slack"]) {
const packageJson = readJson(`/app/dist/extensions/${pluginId}/package.json`);
assert(
packageJson.openclaw?.build?.bundledDist === false,
`${pluginId} bundledDist release metadata changed`,
);
}
const declaredDependencies = {
clickclack: ["ws"],
slack: ["@slack/bolt", "@slack/web-api"],
msteams: ["@microsoft/teams.apps"],
};
for (const [pluginId, dependencies] of Object.entries(declaredDependencies)) {
const packageJson = readJson(`/app/dist/extensions/${pluginId}/package.json`);
for (const dependency of dependencies) {
assert(
typeof packageJson.dependencies?.[dependency] === "string",
`${pluginId} package metadata omitted ${dependency}`,
);
}
}
for (const dependency of ["@microsoft/teams.apps", "@slack/bolt", "@slack/web-api", "ws"]) {
assertFile(path.join("/app/node_modules", dependency, "package.json"));
}
assertFile("/app/dist/extensions/slack/skills/slack/SKILL.md");
assertAbsent("/app/dist/extensions/amazon-bedrock");
assertAbsent("/app/extensions/amazon-bedrock");
assertAbsent("/app/node_modules/@aws-sdk/client-bedrock");
assertAbsent("/app/dist/extensions/signal");
assertAbsent("/app/extensions/signal");
assertAbsent("/home/node/.cache/ms-playwright");
console.log(`Selected-plugin runtime proof passed (${process.arch})`);

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
export HOME=/tmp/openclaw-docker-selected-plugins
export OPENCLAW_STATE_DIR="$HOME/.openclaw"
export OPENCLAW_CONFIG_PATH="$OPENCLAW_STATE_DIR/openclaw.json"
export OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS=1
mkdir -p "$OPENCLAW_STATE_DIR"
node --input-type=module <<'NODE'
import fs from "node:fs";
const entries = Object.fromEntries(
["clickclack", "slack", "msteams"].map((id) => [id, { enabled: true }]),
);
fs.writeFileSync(
process.env.OPENCLAW_CONFIG_PATH,
`${JSON.stringify({ plugins: { entries } }, null, 2)}\n`,
{ mode: 0o600 },
);
NODE
for plugin_id in clickclack slack msteams clawrouter; do
node /app/openclaw.mjs plugins inspect "$plugin_id" --runtime --json \
>"/tmp/openclaw-${plugin_id}-inspect.json"
done
node /openclaw-e2e/assertions.mjs

View File

@@ -13,6 +13,7 @@ export type BundledPluginBuildEntryParams = {
};
export const NON_PACKAGED_BUNDLED_PLUGIN_DIRS: Set<string>;
export const DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV: string;
export function collectRootPackageExcludedExtensionDirs(
params?: BundledPluginBuildEntryParams,
): Set<string>;

View File

@@ -14,6 +14,8 @@ const TOP_LEVEL_PUBLIC_SURFACE_EXTENSIONS = new Set([".ts", ".js", ".mts", ".cts
export const NON_PACKAGED_BUNDLED_PLUGIN_DIRS = new Set(["qa-channel", "qa-lab", "qa-matrix"]);
const EXCLUDED_CORE_BUNDLED_PLUGIN_DIRS = new Set(["qqbot", "whatsapp"]);
const BUNDLED_PLUGIN_BUILD_IDS_ENV = "OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS";
export const DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV = "OPENCLAW_INTERNAL_DOCKER_BUILD_PLUGIN_IDS";
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*$/u;
const TOP_LEVEL_PRIVATE_TEST_SURFACE_RE =
/(?:^|[._-])(?:test|spec|test-support|test-helpers|test-fixtures|test-harness|mock-setup)(?:[._-]|$)/u;
const toPosixPath = (value) => value.replaceAll("\\", "/");
@@ -31,6 +33,28 @@ function parseBundledPluginBuildIdFilter(env = process.env) {
);
}
function parseDockerSelectedPluginBuildIdFilter(env = process.env) {
const raw = env[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV];
if (typeof raw !== "string" || raw.trim() === "") {
return null;
}
const ids = new Set(
raw
.split(/[\s,]+/u)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
);
const invalidIds = [...ids].filter((id) => !PLUGIN_ID_RE.test(id));
if (invalidIds.length > 0) {
throw new Error(
`${DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV} contains invalid plugin id(s): ${invalidIds
.toSorted((left, right) => left.localeCompare(right))
.join(", ")}`,
);
}
return ids;
}
function readBundledPluginPackageJson(packageJsonPath, options = {}) {
if (!(options.hasPackageJson ?? fs.existsSync(packageJsonPath))) {
return null;
@@ -184,7 +208,8 @@ function collectBundledPluginCandidates(cwd, extensionsRoot) {
relativeFiles: null,
topLevelPublicSurfaceEntries: collectTopLevelPublicSurfaceEntries(pluginDir),
};
});
})
.toSorted((left, right) => left.dirName.localeCompare(right.dirName));
}
/** Collect all bundled plugin build entries for the current checkout. */
@@ -192,9 +217,11 @@ export function collectBundledPluginBuildEntries(params = {}) {
const cwd = params.cwd ?? process.cwd();
const env = params.env ?? process.env;
const extensionsRoot = path.join(cwd, BUNDLED_PLUGIN_ROOT_DIR);
const dockerSelectedBuildIds = parseDockerSelectedPluginBuildIdFilter(env);
const candidates = collectBundledPluginCandidates(cwd, extensionsRoot);
const entries = [];
for (const candidate of collectBundledPluginCandidates(cwd, extensionsRoot)) {
for (const candidate of candidates) {
const { dirName, pluginDir, relativeFiles, topLevelPublicSurfaceEntries } = candidate;
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
const hasManifest =
@@ -216,7 +243,7 @@ export function collectBundledPluginBuildEntries(params = {}) {
if (!shouldBuildBundledCluster(dirName, env, { packageJson })) {
continue;
}
if (!shouldBuildBundledDistEntry(packageJson)) {
if (!shouldBuildBundledDistEntry(packageJson) && !dockerSelectedBuildIds?.has(dirName)) {
continue;
}
if (EXCLUDED_CORE_BUNDLED_PLUGIN_DIRS.has(dirName)) {
@@ -237,6 +264,18 @@ export function collectBundledPluginBuildEntries(params = {}) {
});
}
if (dockerSelectedBuildIds) {
const knownIds = new Set(candidates.map((candidate) => candidate.dirName));
const unknownIds = [...dockerSelectedBuildIds].filter((id) => !knownIds.has(id));
if (unknownIds.length > 0) {
throw new Error(
`${DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV} references unknown plugin id(s): ${unknownIds
.toSorted((left, right) => left.localeCompare(right))
.join(", ")}`,
);
}
}
const filteredBuildIds = parseBundledPluginBuildIdFilter(env);
if (!filteredBuildIds) {
return entries;

View File

@@ -262,15 +262,22 @@ function kitchenSinkRpcLane() {
}
export const mainLanes = [
serviceLane(
"compose-setup",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:compose-setup",
lane(
"docker-selected-plugins",
"OPENCLAW_SKIP_DOCKER_BUILD=0 pnpm test:docker:selected-plugins",
{
stateScenario: "empty",
timeoutMs: 20 * 60 * 1000,
weight: 3,
e2eImageKind: false,
estimateSeconds: 600,
resources: ["docker"],
timeoutMs: 30 * 60 * 1000,
weight: 4,
},
),
serviceLane("compose-setup", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:compose-setup", {
stateScenario: "empty",
timeoutMs: 20 * 60 * 1000,
weight: 3,
}),
npmLane(
"docker-package-install",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:package-install",
@@ -933,11 +940,10 @@ export function releasePathChunkLanes(chunk, options = {}) {
export function allReleasePathLanes(options = {}) {
const releaseProfile = normalizeReleaseProfile(options.releaseProfile);
return Object.keys(primaryReleasePathChunks)
.flatMap((chunk) =>
releasePathChunkLanes(chunk, {
includeOpenWebUI: options.includeOpenWebUI,
releaseProfile,
}),
);
return Object.keys(primaryReleasePathChunks).flatMap((chunk) =>
releasePathChunkLanes(chunk, {
includeOpenWebUI: options.includeOpenWebUI,
releaseProfile,
}),
);
}

View File

@@ -0,0 +1,83 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*$/u;
function readManifestId(pluginDir) {
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
if (!fs.existsSync(manifestPath)) {
return null;
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
return typeof manifest.id === "string" && manifest.id.length > 0 ? manifest.id : null;
}
function collectPluginIdentities(extensionsRoot) {
return fs
.readdirSync(extensionsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => {
const pluginDir = path.join(extensionsRoot, entry.name);
const hasPackageJson = fs.existsSync(path.join(pluginDir, "package.json"));
const manifestId = readManifestId(pluginDir);
return {
dirName: entry.name,
manifestId,
known: hasPackageJson || manifestId !== null,
};
})
.filter((entry) => entry.known)
.toSorted((left, right) => left.dirName.localeCompare(right.dirName));
}
/** Resolve public Docker selections to the source directories used by build and prune steps. */
export function resolveDockerPluginSelection(params) {
const selection = typeof params.selection === "string" ? params.selection : "";
const selectedIds = new Set(
selection
.split(/[\s,]+/u)
.map((entry) => entry.trim())
.filter(Boolean),
);
const plugins = collectPluginIdentities(params.extensionsRoot);
const resolvedDirs = new Set();
for (const selectedId of selectedIds) {
if (!PLUGIN_ID_RE.test(selectedId)) {
throw new Error(`invalid OPENCLAW_EXTENSIONS plugin id: ${selectedId}`);
}
const matches = plugins.filter(
(plugin) => plugin.dirName === selectedId || plugin.manifestId === selectedId,
);
if (matches.length === 0) {
throw new Error(`unknown OPENCLAW_EXTENSIONS plugin id: ${selectedId}`);
}
if (matches.length > 1) {
throw new Error(
`ambiguous OPENCLAW_EXTENSIONS plugin id: ${selectedId} (${matches
.map((plugin) => plugin.dirName)
.join(", ")})`,
);
}
resolvedDirs.add(matches[0].dirName);
}
return [...resolvedDirs].toSorted((left, right) => left.localeCompare(right));
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
const resolved = resolveDockerPluginSelection({
extensionsRoot: process.argv[2],
selection: process.argv[3] ?? "",
});
if (resolved.length > 0) {
process.stdout.write(`${resolved.join("\n")}\n`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`ERROR: ${message}`);
process.exitCode = 1;
}
}

View File

@@ -7,6 +7,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as tar from "tar";
import { DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV } from "./lib/bundled-plugin-build-entries.mjs";
import { preparePackageChangelog, restorePackageChangelog } from "./package-changelog.mjs";
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -22,6 +23,11 @@ const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
const AI_RUNTIME_PACKAGE = "@openclaw/ai";
const AI_RUNTIME_BACKUP_DIR = ".openclaw-ai-package-backup";
const ACTIVE_CHILD_KILLERS = new Set();
const PACKAGE_BUILD_PLUGIN_SELECTION_ENV_NAMES = [
"OPENCLAW_EXTENSIONS",
"OPENCLAW_DOCKER_BUILD_EXTENSIONS",
DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV,
];
const SIGNAL_EXIT_CODES = {
SIGHUP: 129,
SIGINT: 130,
@@ -365,13 +371,19 @@ const PACKAGE_ARTIFACT_BUILD_STEPS = [
export async function buildPackageArtifacts(sourceDir, options = {}) {
const runImpl = options.runImpl ?? run;
const buildEnv = {
...process.env,
OPENCLAW_BUILD_ALL_NO_PNPM: "1",
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "0",
};
for (const envName of PACKAGE_BUILD_PLUGIN_SELECTION_ENV_NAMES) {
delete buildEnv[envName];
}
for (const step of PACKAGE_ARTIFACT_BUILD_STEPS) {
console.error(`==> ${step.label}`);
await runImpl(step.command, step.args, sourceDir, {
env: {
...process.env,
OPENCLAW_BUILD_ALL_NO_PNPM: "1",
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "0",
...buildEnv,
},
timeoutMs: resolveTimeoutMs(
"OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS",

View File

@@ -73,6 +73,9 @@ type ReleaseCheckCommandInvocation = {
};
const rootPackageExcludedExtensionDirs = collectRootPackageExcludedExtensionDirs();
const rootPackageExcludedExtensionPrefixes = [...rootPackageExcludedExtensionDirs].map(
(extensionId) => `dist/extensions/${extensionId}/`,
);
const requiredPathGroups = [
"npm-shrinkwrap.json",
PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
@@ -105,6 +108,7 @@ const requiredPathGroups = [
"dist/control-ui/index.html",
];
const forbiddenPrefixes = [
...rootPackageExcludedExtensionPrefixes,
...LOCAL_BUILD_METADATA_DIST_PATHS,
"dist-runtime/",
"dist/OpenClaw.app/",

View File

@@ -152,7 +152,15 @@ describe("Dockerfile", () => {
expect(extensionManifestIndex).toBeGreaterThan(-1);
expect(dockerfile).toContain("for manifest in /tmp/packages/*/package.json");
expect(dockerfile).toContain(
`if [ -f "/tmp/\${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" ]; then`,
"--mount=type=bind,source=scripts/lib/docker-plugin-selection.mjs,target=/tmp/docker-plugin-selection.mjs,readonly",
);
expect(dockerfile).toContain(
'node /tmp/docker-plugin-selection.mjs "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}" "$OPENCLAW_EXTENSIONS"',
);
expect(dockerfile).toContain("done < /out/openclaw-selected-plugin-dirs");
expect(dockerfile).toContain(`if [ -f "$ext_dir/package.json" ]; then`);
expect(dockerfile).toContain(
"COPY --from=workspace-deps /out/openclaw-selected-plugin-dirs /tmp/openclaw-selected-plugin-dirs",
);
expect(postinstallIndex).toBeLessThan(installIndex);
expect(prepareIndex).toBeLessThan(installIndex);
@@ -161,6 +169,24 @@ describe("Dockerfile", () => {
expect(extensionManifestIndex).toBeLessThan(installIndex);
});
it("keeps validated plugin selection outside the build-context copy destination", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
const selectionCopyIndex = dockerfile.indexOf(
"COPY --from=workspace-deps /out/openclaw-selected-plugin-dirs /tmp/openclaw-selected-plugin-dirs",
);
const buildContextCopyIndex = dockerfile.indexOf("COPY . .");
expect(selectionCopyIndex).toBeGreaterThan(-1);
expect(buildContextCopyIndex).toBeGreaterThan(selectionCopyIndex);
expect(dockerfile).not.toContain("/app/.openclaw-selected-plugin-dirs");
expect(dockerfile).not.toContain("./.openclaw-selected-plugin-dirs");
expect(dockerfile).toContain("grep -qx 'matrix' /tmp/openclaw-selected-plugin-dirs");
expect(dockerfile).toContain(
'selected_plugin_dirs="$(cat /tmp/openclaw-selected-plugin-dirs)"',
);
expect(dockerfile).toContain('OPENCLAW_EXTENSIONS="$(cat /tmp/openclaw-selected-plugin-dirs)"');
});
it("copies root package lifecycle scripts before pnpm install", async () => {
const [dockerfile, packageJsonText] = await Promise.all([
readFile(dockerfilePath, "utf8"),
@@ -199,7 +225,7 @@ describe("Dockerfile", () => {
"export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1",
);
const buildDockerIndex = collapsed.indexOf(
'OPENCLAW_RUN_NODE_SKIP_DTS_BUILD="$OPENCLAW_DOCKER_BUILD_SKIP_DTS" OPENCLAW_TSDOWN_MAX_OLD_SPACE_MB="$OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB" NODE_OPTIONS="$OPENCLAW_DOCKER_BUILD_NODE_OPTIONS" pnpm_config_verify_deps_before_run=false pnpm build:docker',
'OPENCLAW_INTERNAL_DOCKER_BUILD_PLUGIN_IDS="$selected_plugin_dirs" OPENCLAW_RUN_NODE_SKIP_DTS_BUILD="$OPENCLAW_DOCKER_BUILD_SKIP_DTS" OPENCLAW_TSDOWN_MAX_OLD_SPACE_MB="$OPENCLAW_DOCKER_BUILD_TSDOWN_MAX_OLD_SPACE_MB" NODE_OPTIONS="$OPENCLAW_DOCKER_BUILD_NODE_OPTIONS" pnpm_config_verify_deps_before_run=false pnpm build:docker',
);
const qaLabBuildIndex = collapsed.indexOf(
"pnpm_config_verify_deps_before_run=false pnpm qa:lab:build",
@@ -211,6 +237,9 @@ describe("Dockerfile", () => {
expect(qaLabExtensionCheckIndex).toBeGreaterThan(-1);
expect(buildDockerIndex).toBeGreaterThan(-1);
expect(collapsed).not.toContain(
'OPENCLAW_DOCKER_BUILD_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_RUN_NODE_SKIP_DTS_BUILD=',
);
expect(qaLabBuildIndex).toBeGreaterThan(-1);
expect(qaLabDistCopyIndex).toBeGreaterThan(-1);
expect(runtimeAssetsIndex).toBeGreaterThan(-1);
@@ -246,12 +275,23 @@ describe("Dockerfile", () => {
it("documents provenance arguments for manual source builds", async () => {
const docs = await readFile(dockerInstallDocsPath, "utf8");
const selectedPluginStart = docs.indexOf("### Source-built images with selected plugins");
const selectedPluginEnd = docs.indexOf("### Observability", selectedPluginStart);
const selectedPluginDocs = docs.slice(selectedPluginStart, selectedPluginEnd);
expect(docs).toContain('BUILD_GIT_COMMIT="$(git rev-parse HEAD)"');
expect(docs).toContain('BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"');
expect(docs).toContain('--build-arg "GIT_COMMIT=${BUILD_GIT_COMMIT}"');
expect(docs).toContain('--build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}"');
expect(docs).toContain("The Docker context excludes `.git`.");
expect(selectedPluginStart).toBeGreaterThan(-1);
expect(selectedPluginEnd).toBeGreaterThan(selectedPluginStart);
expect(selectedPluginDocs).toContain('SOURCE_SHA="$(git rev-parse HEAD)"');
expect(selectedPluginDocs).toContain('BUILD_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"');
expect(selectedPluginDocs).toContain('--build-arg "GIT_COMMIT=${SOURCE_SHA}"');
expect(selectedPluginDocs).toContain(
'--build-arg "OPENCLAW_BUILD_TIMESTAMP=${BUILD_TIMESTAMP}"',
);
});
it("prunes runtime dependencies and omitted plugin packages after the build stage", async () => {
@@ -265,7 +305,7 @@ describe("Dockerfile", () => {
expect(dockerfile).toContain("ARG OPENCLAW_DOCKER_BUILD_SKIP_DTS=1");
expect(dockerfile).toContain("ARG OPENCLAW_BUNDLED_PLUGIN_DIR");
expect(dockerfile).toContain(
"Opt-in plugin dependencies at build time (space- or comma-separated directory names).",
"Opt-in plugin dependencies and supported runtime builds (space- or comma-separated ids).",
);
expect(dockerfile).toContain(
'Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,matrix" .',
@@ -278,14 +318,14 @@ describe("Dockerfile", () => {
"COPY --from=workspace-deps /out/${OPENCLAW_BUNDLED_PLUGIN_DIR}/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/",
);
expect(dockerfile).toContain(
'OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs',
'OPENCLAW_EXTENSIONS="$(cat /tmp/openclaw-selected-plugin-dirs)" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs',
);
expect(dockerfile).toContain("readlink -f /app/node_modules/@openclaw/ai");
expect(dockerfile).toContain('mv "$ai_runtime_tmp/ai" /app/node_modules/@openclaw/ai');
expect(dockerfile).toContain("CI=true pnpm prune --prod \\");
expect(dockerfile.indexOf("CI=true pnpm prune --prod \\")).toBeLessThan(
dockerfile.indexOf(
'OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs',
'OPENCLAW_EXTENSIONS="$(cat /tmp/openclaw-selected-plugin-dirs)" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs',
),
);
expect(dockerfile).toContain("--config.offline=true");

View File

@@ -6,6 +6,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { describe, expect, it, vi } from "vitest";
import { DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV } from "../../../../scripts/lib/bundled-plugin-build-entries.mjs";
import {
buildPackageArtifacts,
packOpenClawPackageForDocker,
@@ -157,13 +158,22 @@ describe("package-openclaw-for-docker", () => {
args: string[];
cwd: string;
noPnpm: string | undefined;
packageExtensions: string | undefined;
dockerBuildExtensions: string | undefined;
internalDockerBuildPluginIds: string | undefined;
skipDts: string | undefined;
timeoutMs: number | undefined;
}> = [];
const previousTimeout = process.env.OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS;
const previousSkipDts = process.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD;
const previousPackageExtensions = process.env.OPENCLAW_EXTENSIONS;
const previousDockerBuildExtensions = process.env.OPENCLAW_DOCKER_BUILD_EXTENSIONS;
const previousInternalPluginIds = process.env[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV];
process.env.OPENCLAW_DOCKER_PACKAGE_BUILD_TIMEOUT_MS = "1234";
process.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD = "1";
process.env.OPENCLAW_EXTENSIONS = "clickclack";
process.env.OPENCLAW_DOCKER_BUILD_EXTENSIONS = "slack";
process.env[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV] = "msteams";
try {
await buildPackageArtifacts("/repo", {
@@ -178,6 +188,9 @@ describe("package-openclaw-for-docker", () => {
args,
cwd,
noPnpm: options.env?.OPENCLAW_BUILD_ALL_NO_PNPM,
packageExtensions: options.env?.OPENCLAW_EXTENSIONS,
dockerBuildExtensions: options.env?.OPENCLAW_DOCKER_BUILD_EXTENSIONS,
internalDockerBuildPluginIds: options.env?.[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV],
skipDts: options.env?.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD,
timeoutMs: options.timeoutMs,
});
@@ -194,6 +207,17 @@ describe("package-openclaw-for-docker", () => {
} else {
process.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD = previousSkipDts;
}
for (const [envName, previousValue] of [
["OPENCLAW_EXTENSIONS", previousPackageExtensions],
["OPENCLAW_DOCKER_BUILD_EXTENSIONS", previousDockerBuildExtensions],
[DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV, previousInternalPluginIds],
] as const) {
if (previousValue === undefined) {
delete process.env[envName];
} else {
process.env[envName] = previousValue;
}
}
}
expect(calls).toEqual([
@@ -201,7 +225,10 @@ describe("package-openclaw-for-docker", () => {
command: "node",
args: ["scripts/build-all.mjs", "ciArtifacts"],
cwd: "/repo",
dockerBuildExtensions: undefined,
internalDockerBuildPluginIds: undefined,
noPnpm: "1",
packageExtensions: undefined,
skipDts: "0",
timeoutMs: 1234,
},

View File

@@ -525,6 +525,21 @@ describe("collectForbiddenPackPaths", () => {
).toEqual([...LOCAL_BUILD_METADATA_DIST_PATHS]);
});
it("blocks Docker-selected external plugin trees from the core npm pack", () => {
expect(
collectForbiddenPackPaths([
"dist/index.js",
"dist/extensions/clickclack/index.js",
"dist/extensions/slack/setup-entry.js",
"dist/extensions/msteams/openclaw.plugin.json",
]),
).toEqual([
"dist/extensions/clickclack/index.js",
"dist/extensions/msteams/openclaw.plugin.json",
"dist/extensions/slack/setup-entry.js",
]);
});
it("keeps local build metadata excluded by package files", () => {
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { files?: string[] };
for (const entry of LOCAL_BUILD_METADATA_DIST_PATHS) {

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);
}
});
});