From 10ebcbdb998610252b205ea44935103b2525261f Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Sat, 2 May 2026 10:46:51 -0400 Subject: [PATCH] fix(docker): replace curl|bash Bun install with pinned multi-stage COPY (#74359) Merged via squash. Prepared head SHA: 3b4a889467208ef578e1ab141977c11361a20829 Co-authored-by: fede-kamel <209537060+fede-kamel@users.noreply.github.com> Co-authored-by: sallyom <11166065+sallyom@users.noreply.github.com> Reviewed-by: @sallyom --- CHANGELOG.md | 1 + Dockerfile | 19 +++------ src/docker-image-digests.test.ts | 72 ++++++++++++++++++++++++-------- 3 files changed, 61 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c8186ea0d..5b202d0bade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Gateway/sessions: keep `sessions.list` polling responsive on large session stores by reusing list-safe session cache/indexes and returning a lightweight compaction checkpoint preview instead of heavyweight summaries. Thanks @rolandrscheel. - CLI/update: treat inherited Gateway service markers as origin hints and only block package replacement when the managed Gateway is still live, so self-updates can stop the service and continue safely. (#75729) Thanks @hxy91819. - Agents/failover: exempt run-level timeouts that fire during tool execution from model fallback, timeout-triggered compaction, and generic timeout payload synthesis. Long `process(poll)`, browser, or `exec` tool calls that exceed `agents.defaults.timeoutSeconds` previously rotated auth profiles, switched to a fallback model, and surfaced a misleading "LLM request timed out" error even though the primary model had already responded. Mirrors the existing `timedOutDuringCompaction` precedent (#46889). Fixes #52147. (#75873) Thanks @simonusa. +- Docker: copy Bun 1.3.13 from a digest-pinned image and keep CI on the same version. Fixes #74356. Thanks @fede-kamel and @sallyom. ## 2026.5.2 diff --git a/Dockerfile b/Dockerfile index 37971e6439e..60b50869fbc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,9 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR=extensions ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b" ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb" ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb" +# Keep in sync with .github/actions/setup-node-env/action.yml bun-version. +# To update: docker buildx imagetools inspect oven/bun: and use the manifest-list digest. +ARG OPENCLAW_BUN_IMAGE="oven/bun:1.3.13@sha256:87416c977a612a204eb54ab9f3927023c2a3c971f4f345a01da08ea6262ae30e" # Base images are pinned to SHA256 digests for reproducible builds. # Dependabot refreshes these blessed digests; release builds consume the @@ -37,22 +40,12 @@ RUN --mount=type=bind,source=${OPENCLAW_BUNDLED_PLUGIN_DIR},target=/tmp/${OPENCL done # ── Stage 2: Build ────────────────────────────────────────────── +FROM ${OPENCLAW_BUN_IMAGE} AS bun-binary FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build ARG OPENCLAW_BUNDLED_PLUGIN_DIR -# Install Bun (required for build scripts). Retry the whole bootstrap flow to -# tolerate transient 5xx failures from bun.sh/GitHub during CI image builds. -RUN set -eux; \ - for attempt in 1 2 3 4 5; do \ - if curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL https://bun.sh/install | bash; then \ - break; \ - fi; \ - if [ "$attempt" -eq 5 ]; then \ - exit 1; \ - fi; \ - sleep $((attempt * 2)); \ - done -ENV PATH="/root/.bun/bin:${PATH}" +# Copy pinned Bun binary from the official image instead of fetching via curl. +COPY --from=bun-binary /usr/local/bin/bun /usr/local/bin/bun RUN corepack enable diff --git a/src/docker-image-digests.test.ts b/src/docker-image-digests.test.ts index 024cd9df7dc..cf48dfbc8ff 100644 --- a/src/docker-image-digests.test.ts +++ b/src/docker-image-digests.test.ts @@ -33,43 +33,67 @@ type DependabotConfig = { updates?: DependabotUpdate[]; }; -function resolveFirstFromReference(dockerfile: string): string | undefined { +function resolveArgDefaults(dockerfile: string): Map { const argDefaults = new Map(); - for (const line of dockerfile.split(/\r?\n/)) { const trimmed = line.trim(); - if (!trimmed) { - continue; - } - if (trimmed.startsWith("FROM ")) { - break; - } const argMatch = trimmed.match(/^ARG\s+([A-Z0-9_]+)=(.+)$/); if (!argMatch) { continue; } const [, name, rawValue] = argMatch; - const value = rawValue.replace(/^["']|["']$/g, ""); - argDefaults.set(name, value); - } - - const fromLine = dockerfile.split(/\r?\n/).find((line) => line.trimStart().startsWith("FROM ")); - if (!fromLine) { - return undefined; + argDefaults.set(name, rawValue.replace(/^["']|["']$/g, "")); } + return argDefaults; +} +function resolveFromImageRef(fromLine: string, argDefaults: Map): string { const fromMatch = fromLine.trim().match(/^FROM\s+(\S+?)(?:\s+AS\s+\S+)?$/); if (!fromMatch) { - return undefined; + return fromLine; } const imageRef = fromMatch[1]; const argName = imageRef.match(/^\$\{([A-Z0-9_]+)\}$/)?.[1] ?? imageRef.match(/^\$([A-Z0-9_]+)$/)?.[1]; - if (!argName) { return imageRef; } - return argDefaults.get(argName); + return argDefaults.get(argName) ?? imageRef; +} + +function resolveAllArgBackedFromReferences( + dockerfile: string, +): { stage: string; imageRef: string }[] { + const argDefaults = resolveArgDefaults(dockerfile); + const results: { stage: string; imageRef: string }[] = []; + let stageIndex = 0; + for (const line of dockerfile.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("FROM ")) { + continue; + } + const imageRef = resolveFromImageRef(trimmed, argDefaults); + // Only check FROM lines that use an ARG — literal `FROM scratch` etc. are intentionally unpinned. + const usesArg = + trimmed.match(/FROM\s+\$\{[A-Z0-9_]+\}/) !== null || + trimmed.match(/FROM\s+\$[A-Z0-9_]+/) !== null; + if (usesArg) { + const stageMatch = trimmed.match(/AS\s+(\S+)/i); + const stageName = stageMatch ? stageMatch[1] : `stage-${stageIndex}`; + results.push({ stage: stageName, imageRef }); + } + stageIndex += 1; + } + return results; +} + +function resolveFirstFromReference(dockerfile: string): string | undefined { + const argDefaults = resolveArgDefaults(dockerfile); + const fromLine = dockerfile.split(/\r?\n/).find((line) => line.trimStart().startsWith("FROM ")); + if (!fromLine) { + return undefined; + } + return resolveFromImageRef(fromLine, argDefaults); } describe("docker base image pinning", () => { @@ -84,6 +108,18 @@ describe("docker base image pinning", () => { } }); + it("pins all ARG-backed FROM stages in selected Dockerfiles to sha256 digests", async () => { + for (const dockerfilePath of DIGEST_PINNED_DOCKERFILES) { + const dockerfile = await readFile(resolve(repoRoot, dockerfilePath), "utf8"); + const stages = resolveAllArgBackedFromReferences(dockerfile); + for (const { stage, imageRef } of stages) { + expect(imageRef, `${dockerfilePath} stage "${stage}" must be digest-pinned`).toMatch( + /^\S+@sha256:[a-f0-9]{64}$/, + ); + } + } + }); + it("keeps Dependabot Docker updates enabled for root Dockerfiles", async () => { const raw = await readFile(resolve(repoRoot, ".github/dependabot.yml"), "utf8"); const config = parse(raw) as DependabotConfig;