Repository navigation
Expand file tree
/
Copy pathDockerfile
More file actions
404 lines (373 loc) · 21.4 KB
/
Copy pathDockerfile
File metadata and controls
404 lines (373 loc) · 21.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# =============================================================================
# TFactory — Chainguard distroless build
# =============================================================================
# Epic #26 (issue #27): port from the legacy Ubuntu Dockerfile to a
# Chainguard base. Each P0 chunk turns one or more tests in tests/docker/
# from skipped → passing.
# =============================================================================
# ---------------------------------------------------------------------------
# Stage 1: Build the React frontend
# ---------------------------------------------------------------------------
# Digest is the OCI image-index (manifest-list) sha256 so multi-arch buildx
# (P0.6) can still resolve the right platform manifest. The version tag is
# kept alongside the digest as a human hint and is ignored by docker when a
# digest is present.
#
# Why not cgr.dev/chainguard/node (AIFactory#1091): this stage was pinned to
# `node:latest-dev@sha256:...`, which cannot work. Chainguard rebuilds
# `latest-*` continuously and garbage-collects superseded digests, so the pin
# 404s within days and `load metadata` fails before the build starts. The
# usual answer -- pin a versioned tag by digest -- is not available: the
# public Chainguard catalog publishes only `latest`, `latest-dev`,
# `latest-slim`, `next` and `next-dev` for node; `node:24-dev` and `node:24`
# both 404, because versioned tags are a paid Production-tier feature under a
# customer-specific org path. Docker Official Images do retain superseded
# digests for versioned tags (node:24.0.0-bookworm-slim from May 2025 still
# resolves), so a pin here survives while staying reproducible.
#
# node >= 24.0.0 and npm >= 10 are required by the root package.json
# `engines`. Keep a glibc variant: an Alpine/musl image would need the
# `-musl` Rollup native binaries rather than `-gnu`.
# Nothing from this stage ships -- only apps/web-server/static is copied into
# the runtime stage -- so the base's CVE posture is not part of the attack
# surface. The runtime stage stays on Chainguard, where it does matter.
# Digest bumps land via Dependabot PRs (.github/dependabot.yml).
FROM docker.io/node:26-bookworm-slim@sha256:c8fedd782bcd1b68d8a7d1ed2577b5f820eba820871323f605292651ff11e3c6 AS frontend-build
USER root
WORKDIR /build
# Workspace-aware install via root package + the frontend's package.json
COPY package.json package-lock.json ./
COPY apps/frontend-web/package.json apps/frontend-web/
RUN npm ci --workspace=apps/frontend-web
COPY apps/frontend-web/ apps/frontend-web/
# vite.config.ts: build.outDir = '../web-server/static'
RUN mkdir -p apps/web-server/static \
&& cd apps/frontend-web \
&& npm run build
# ---------------------------------------------------------------------------
# Stage 2: Runtime (Chainguard Python, dev variant for now — minimal split
# happens in P0.5 once we know what the runtime *actually* needs)
# ---------------------------------------------------------------------------
# Runtime Node comes from the official image, not apk (Factory#1710). Same
# digest as frontend-build, so both move together in one Dependabot bump.
FROM docker.io/node:26-bookworm-slim@sha256:c8fedd782bcd1b68d8a7d1ed2577b5f820eba820871323f605292651ff11e3c6 AS node-runtime
FROM cgr.dev/chainguard/python:latest-dev@sha256:96cb9c155159daf6b21e70555f244081909ff161c5589112ddf308624c1a1c77 AS runtime
USER root
# Pull all available Wolfi security patches at build time. The base is pinned
# by digest for reproducibility, but a pinned digest lags behind freshly-
# disclosed CVEs. When the rolling Wolfi repo is ahead of the pinned snapshot,
# `apk upgrade` clears fixable HIGH/CRITICAL findings on each rebuild without a
# digest bump — the between-bumps guard (cf. the binutils constraint below).
# When the snapshot itself lags (the repo has no newer rev yet), bump the base
# digest above to a Chainguard rebuild that ships the fix — that's what cleared
# CVE-2026-45447 (libcrypto3/libssl3 3.6.2-r5 → 3.6.3-r1). Dependabot automates
# the digest bumps; this RUN covers the window in between.
#
# SECURITY_REFRESH busts THIS layer's cache. CI builds with `cache-from`, so
# without it the upgrade layer is served from cache and never re-runs -- the
# image keeps shipping whatever Debian shipped the day the layer was first
# built. That is how CFactory#440 hit a Trivy failure for CVE-2026-14456 on an
# image whose Dockerfile already ran `apt-get upgrade`: the upgrade was real,
# its result was frozen. CI passes the date, so it rebuilds at most daily.
ARG SECURITY_REFRESH=0
RUN echo "security refresh: ${SECURITY_REFRESH}" \
&& apk upgrade --no-cache
# Node from the official image (Factory#1710). apk `nodejs` is rebuilt on the
# rolling index against the newest glibc, while this base pins glibc exactly in
# /etc/apk/world, and apk deps are unversioned sonames. apk node needed exactly
# the image's GLIBC_2.44 (zero headroom) and broke every PR on 2026-09-03. The
# official binary needs GLIBC_2.28 and only libc/libm/libdl/libpthread/
# libstdc++/libgcc_s/libatomic (all in the base); libuv/OpenSSL/ICU are bundled.
COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
COPY --from=node-runtime /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
# .nvmrc is the one declaration of the Node major: fail the build on drift.
COPY .nvmrc /tmp/.nvmrc
RUN want="$(tr -dc '0-9.' < /tmp/.nvmrc | cut -d. -f1)" \
&& have="$(node -p 'process.versions.node.split(".")[0]')" \
&& [ -n "$want" ] && [ "$want" = "$have" ] \
|| { echo "Node major drift: .nvmrc=$want runtime=$have (Factory#1710)"; exit 1; } \
&& rm /tmp/.nvmrc
# System packages from Wolfi APK index. Build tools come bundled in :latest-dev.
# git — worktree operations
# curl, wget — downloads (HEALTHCHECK uses curl)
# gh — GitHub CLI (Wolfi apk package name)
# (Node is NOT from apk: see the node-runtime COPY below, Factory#1710.)
# ca-certificates — TLS roots
# bash — entrypoint script (will be removed in P0.3)
# binutils — the :latest-dev base bundles binutils 2.46-r1, which carries
# CVE-2026-6846 (HIGH, heap overflow in XCOFF linking; fixed in
# 2.46-r2). Force a build-newer rev to clear the P0.8 Trivy gate
# (test_trivy_no_high_critical). Constraint, not =2.46-r2, so
# the build stays green when Wolfi revs the package further;
# drop this once the base digest ships the fix (Dependabot).
# libexpat1 — the :latest-dev base bundles libexpat1 2.8.1-r1, which
# carries CVE-2026-56131/56407/56408 (HIGH; use-after-free +
# integer overflows, fixed in 2.8.2-r0). The pinned snapshot
# lags so `apk upgrade` can't reach it; force a build-newer rev
# to clear the P0.8 Trivy gate. Constraint (not =2.8.2-r0) so
# the build stays green as Wolfi revs further; drop once the
# base digest ships the fix (Dependabot).
# bubblewrap — OS-level bash sandbox for agent commands. Without it the
# Claude Agent SDK logs "Sandbox disabled: ... bubblewrap
# (bwrap) not installed" and runs commands with NO filesystem
# /network enforcement — unacceptable for enterprise use. The
# cluster node allows unprivileged user namespaces, so bwrap
# can create the sandbox.
# socat — required alongside bwrap by the SDK sandbox network-proxy
# path; its absence triggers the same warning.
# Two version floors below are CVE remediation, not preference. `apk upgrade`
# earlier in this stage does not clear either, because both packages come from
# the base image layer and must be named explicitly to be pulled forward:
# libssl3 / libcrypto3 3.6.3-r3 — CVE-2026-14456 (HIGH), fixed in 3.6.3-r5.
# Named together because they ship from the same openssl
# origin and apk will not move one without the other.
# busybox 1.37.0-r61 — CVE-2026-38753 (DoS via crafted AWK in awk_sub) and
# CVE-2026-38754 (heap overflow in ifsbreakup, shell/ash.c),
# both HIGH, both fixed in 1.38.0-r0.
# wget 1.25.0-r14 — CVE-2026-58471 and CVE-2026-58472, both HIGH, both
# fixed in 1.25.0-r15.
# curl / libcurl-openssl4 8.22.0-r4 — CVE-2026-92392 (HIGH), fixed in
# 8.23.0-r0. Named together: same origin.
# ca-certificates-bundle 20260909-r2 — CVE-2026-111943 (HIGH), fixed in
# 20260909-r3.
# uv 0.12.22-r0 — CVE-2026-111943 (HIGH, same Go x509 denylist), fixed in
# 0.13.6-r1. Agents launch MCP servers with uvx.
# libbrotlicommon1 / libbrotlidec1 / libbrotlienc1 1.2.0-r5 — CVE-2026-111497
# (HIGH), fixed in 1.2.0-r7. Named together: same origin.
# py3-pip-wheel 26.2.1-r2 — CVE-2026-114613 (HIGH), msgpack map-deserialization
# leak in pip's vendored msgpack 1.2.1; fixed in 26.2.2-r1
# (vendors 1.2.3). python-3.14 depends on it, so it cannot
# be apk-deleted with the other pip packages below.
# Floors rather than == pins, so the build stays green as Wolfi revs further
# (1.38.0-r1 and later already exist); drop each once the base digest ships it.
# Verified present in the Wolfi x86_64 APKINDEX, then confirmed against the
# pinned base digest itself — a floor above the newest available version fails
# the build outright.
RUN apk add --no-cache \
bash \
"busybox>=1.38.0-r0" \
"libcrypto3>=3.6.3-r5" \
"libssl3>=3.6.3-r5" \
"binutils>2.46-r1" \
bubblewrap \
ca-certificates \
"ca-certificates-bundle>=20260909-r3" \
"uv>=0.13.6-r1" \
"curl>=8.23.0-r0" \
"libcurl-openssl4>=8.23.0-r0" \
"libbrotlicommon1>=1.2.0-r7" \
"libbrotlidec1>=1.2.0-r7" \
"libbrotlienc1>=1.2.0-r7" \
"py3-pip-wheel>=26.2.2-r1" \
git \
gh \
gnupg \
"libexpat1>2.8.1-r1" \
socat \
"wget>=1.25.0-r15"
# The npm copied from the node-runtime stage above ships with VENDORED deps carry HIGH CVEs
# that the P0 Trivy gate rejects. Measured in the running image:
# /usr/local/lib/node_modules/npm/node_modules holds brace-expansion 5.0.9 and
# undici 6.28.0, and Trivy attributes all three findings to exactly that path:
#
# HIGH CVE-2026-102276 brace-expansion 5.0.9 -> 5.0.10 (stack exhaustion)
# HIGH CVE-2026-102278 brace-expansion 5.0.9 -> 5.0.11 (uncontrolled recursion)
# HIGH CVE-2026-19534 undici 6.28.0 -> 6.28.1 (WebSocket DoS)
# HIGH CVE-2026-109869 undici 6.28.1 -> 6.29.1
#
# These are npm's own bundle, not a dependency of this application, so no
# lockfile of ours can clear them — and no npm release does either. Measured
# against the registry in AIFactory#1637: 11.19.1, 11.21.0 and 12.2.0 (latest)
# all still bundle the vulnerable versions. Waiting for a newer npm waits
# forever.
#
# Patched versions of the PACKAGES do exist (brace-expansion 5.0.12, undici
# 6.29.1); they are simply in no npm tarball. So replace npm's vendored copies
# rather than allow-listing the CVEs: a .trivyignore entry is for "no known
# patched version exists", which is false here, and silencing the report would
# leave the vulnerable code in an image that runs npm against untrusted
# repository content.
#
# Ported verbatim from AIFactory's Dockerfile (#1637, merged), which is the same
# fix for the same bundle at the same path. If you change one, change both.
#
# The asserting `node -e` is load-bearing, not decoration: a `for` loop exits
# with the status of its LAST iteration, so a brace-expansion failure followed
# by an undici success would exit 0 and ship an unpatched bundle. The trailing
# `npm --version` proves npm still runs after the surgery.
RUN M=/usr/local/lib/node_modules/npm/node_modules \
&& for p in brace-expansion@5.0.12 undici@6.29.1; do \
n="${p%@*}"; \
npm pack "$p" --pack-destination /tmp >/dev/null \
&& rm -rf "$M/$n" && mkdir -p "$M/$n" \
&& tar xzf /tmp/"$n"-*.tgz -C "$M/$n" --strip-components=1 \
&& rm -f /tmp/"$n"-*.tgz; \
done \
&& node -e "const want={'brace-expansion':'5.0.12','undici':'6.29.1'}; let bad=0; for (const [n,v] of Object.entries(want)) { const got=require('/usr/local/lib/node_modules/npm/node_modules/'+n+'/package.json').version; console.log(n, got, got===v?'ok':'EXPECTED '+v); if (got!==v) bad=1; } process.exit(bad)" \
&& npm --version
# Node must come only from the node-runtime COPY above, never from apk: an apk
# nodejs would be rebuilt against a glibc newer than this base pins and break
# the build (Factory#1710). Checked AFTER the apk block, where it could appear.
RUN if apk info 2>/dev/null | grep -q '^nodejs'; then \
echo "apk nodejs is installed; runtime Node must come from node-runtime (Factory#1710)"; exit 1; \
fi
# Epic #44 R3 — optionally bundle the rmux binary.
#
# Build args:
# WITH_RMUX=false (default — bank-pilot image; no rmux binary at all)
# WITH_RMUX=true (dev/demo image; pins rmux v0.3.1 by SHA-256)
#
# CI matrix builds both: ``tfactory:vX`` (default) and ``tfactory:vX-rmux``.
# Bank-pilot image's Trivy report + Syft SBOM contain no rmux components.
#
# Arch support: only ``x86_64-unknown-linux-gnu`` is available upstream as
# of v0.3.1 (no aarch64 Linux build yet — tracked in Helvesec/rmux roadmap).
# ARM64 builds fail-fast with a clear message rather than silently install
# the wrong binary.
ARG WITH_RMUX=false
ARG RMUX_VERSION=0.3.1
# SHA-256 of rmux-v0.3.1-x86_64-unknown-linux-gnu.tar.gz from upstream
# SHA256SUMS file. Bump together with RMUX_VERSION on upgrades.
ARG RMUX_SHA256_AMD64=511d3caceea4fcbc1458877a192efffcde5ceb1455f040f1a79c63ab00804cf8
RUN if [ "$WITH_RMUX" = "true" ]; then \
arch="$(uname -m)"; \
case "$arch" in \
x86_64) \
target="x86_64-unknown-linux-gnu"; \
sha="${RMUX_SHA256_AMD64}" \
;; \
*) \
echo "WITH_RMUX=true: unsupported arch '$arch' (rmux v${RMUX_VERSION} ships x86_64 Linux only)" >&2; \
exit 1 \
;; \
esac; \
curl -fsSL "https://github.com/Helvesec/rmux/releases/download/v${RMUX_VERSION}/rmux-v${RMUX_VERSION}-${target}.tar.gz" \
-o /tmp/rmux.tar.gz; \
echo "${sha} /tmp/rmux.tar.gz" | sha256sum -c -; \
mkdir -p /tmp/rmux-extract; \
tar -xzf /tmp/rmux.tar.gz -C /tmp/rmux-extract; \
find /tmp/rmux-extract -name rmux -type f -executable -exec install -m 0755 {} /usr/local/bin/rmux \; ; \
rm -rf /tmp/rmux.tar.gz /tmp/rmux-extract; \
/usr/local/bin/rmux -V; \
else \
echo "rmux integration not bundled (WITH_RMUX=false — bank-pilot image)"; \
fi
# Project layout (keeping the legacy path under /home/projects for minimum
# diff with the existing Dockerfile; P0.4 may relocate to /app under nonroot)
RUN mkdir -p /home/projects/MagesticAI \
&& chown -R nonroot:nonroot /home/projects
# Copy project sources (respects .dockerignore)
COPY --chown=nonroot:nonroot . /home/projects/MagesticAI/
# Copy built frontend assets from Stage 1
COPY --from=frontend-build --chown=nonroot:nonroot \
/build/apps/web-server/static/ \
/home/projects/MagesticAI/apps/web-server/static/
# Drop to nonroot for venv + npm config (writeable paths only)
USER nonroot
# Configure npm global install dir under the nonroot home
RUN mkdir -p /home/nonroot/.npm-global \
&& npm config set prefix /home/nonroot/.npm-global
# Bake the provider coder CLIs into the image (#791) so the control-plane boot
# never npm-installs them. The install-clis init container did this at pod start
# and, on a slow/hung npm registry, ran 8+ min and stalled the whole rollout —
# stranding in-flight specs. .npm-global/bin is already on PATH (below). Versions
# are pinned here now; a bump is an image rebuild + canary, same as any other
# dependency.
#
# The pins are watched by the hub `agent-CLI freshness` job
# (Factory/scripts/check_cli_freshness.py --open-bump-pr), which proposes bumps
# as `chore/agent-cli-pins` across all three service repos at once and never
# merges them, plus factory-gitops/.github/workflows/cli-canary.yml, which
# asserts every repo pins all three CLIs identically and that each pin installs
# and launches.
#
# NOT Dependabot, whatever an earlier version of this comment claimed
# (factory-gitops#206). Dependabot's Dockerfile parser reads `FROM` lines only —
# no package-ecosystem parses shell arguments inside a RUN layer, so it cannot
# see the `@version` on the npm install below and never could. It does cover the
# `FROM` lines in this file, and nothing else in it.
#
# `install.cjs` is NOT redundant with the npm postinstall (Factory#383). The
# postinstall downloads the 275 MB platform-native binary correctly, but leaves
# `bin/claude.exe` as the 11-line stub that ships in the package — a script whose
# whole body prints "native binary not installed" and exits 1. So `claude` on
# PATH was broken in every control-plane pod while the binary underneath it ran
# fine, and the stub's own error blames --ignore-scripts / --omit=optional,
# neither of which is used here. Re-running install.cjs completes the swap.
#
# `claude --version` is the point of the fix, not decoration: this shipped broken
# because nothing asserted the CLI works. Full path, since PATH is set for the
# runtime user rather than for RUN.
RUN npm install -g \
@anthropic-ai/claude-code@2.1.310 \
@openai/codex@0.164.0 \
@google/gemini-cli@0.65.0 \
&& node /home/nonroot/.npm-global/lib/node_modules/@anthropic-ai/claude-code/install.cjs \
&& /home/nonroot/.npm-global/bin/claude --version \
&& npm cache clean --force \
&& ln -sf /home/nonroot/.npm-global/bin/gemini /home/nonroot/.npm-global/bin/antigravity
# Single Python venv shared by web-server and backend scripts (matches
# agent_service.py's sys.executable expectations)
RUN python3 -m venv /home/projects/MagesticAI/.venv
# pip is removed from the venv in the same layer that finishes using it, and
# the base copies are apk-deleted below. Ported from PFactory#679, which hit
# and solved this first; TFactory#1277 is the same two findings here.
#
# The two HIGH findings this clears are NOT project dependencies — nothing in
# either requirements file needs msgpack or constrains setuptools. Both are
# declared by pip 26.2.1's own vendored SBOM, `pip/_vendor/bom.cdx.json`
# (msgpack 1.1.2, GHSA-6v7p-g79w-8964; setuptools 70.3.0, CVE-2025-47273),
# which is what Trivy reads. A requirements pin therefore cannot touch them:
# verified by installing `msgpack>=1.2.1` and `setuptools>=78.1.1` into this
# venv and re-scanning — pip 26.2.1 ships 1.2.2 and 84.0.0 ALONGSIDE the
# vendored entries and Trivy still reports 1.1.2 and 70.3.0. No pip upgrade
# clears them either: 26.2.1 IS the latest release, and pip main still vendors
# setuptools 70.3.0.
#
# pip is build-time-only here — runtime never installs packages, and cluster
# egress allows apk + npm only, so an in-pod `pip install` could not reach PyPI
# anyway. `provider_runtime.install_argv` has a `sys.executable -m pip install`
# branch for the claude-agent-sdk; PFactory carries that identical branch and
# still removes pip, so this keeps the three services consistent.
RUN /home/projects/MagesticAI/.venv/bin/pip install --no-cache-dir \
-r /home/projects/MagesticAI/apps/web-server/requirements.txt \
-r /home/projects/MagesticAI/apps/backend/requirements.txt \
&& /home/projects/MagesticAI/.venv/bin/pip uninstall -y pip
# Remove the base image's pip too (same findings, second copy).
# py3-pip-wheel ships deliberately: python-3.14 depends on it, so apk refuses to
# drop it (warns, exits 0). Its CVEs are cleared by its floor above. The apk info
# checks fail the build if the removal ever silently no-ops.
USER root
RUN apk del --no-cache py3.14-pip py3.14-pip-base \
&& ! apk info -e py3.14-pip && ! apk info -e py3.14-pip-base
USER nonroot
# Git identity for in-container worktree operations
RUN git config --global user.name "TFactory" \
&& git config --global user.email "tfactory@container"
# Persistent data directory
RUN mkdir -p /home/nonroot/.tfactory
# ---------------------------------------------------------------------------
# Environment
# ---------------------------------------------------------------------------
ENV APP_HOST=0.0.0.0 \
APP_PORT=3102 \
APP_BACKEND_PATH=/home/projects/MagesticAI/apps/backend \
APP_PROJECTS_DATA_DIR=/home/nonroot/.tfactory \
APP_DEFAULT_SHELL=/bin/bash \
PYTHONUNBUFFERED=1 \
PATH="/home/nonroot/.npm-global/bin:/home/projects/MagesticAI/.venv/bin:$PATH"
EXPOSE 3102
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3102/api/health || exit 1
WORKDIR /home/projects/MagesticAI/apps/web-server
# Direct CMD — no shell wrapper. Egress control belongs in K8s NetworkPolicy
# (P4 of Epic #26), not in an entrypoint script. Runs as `nonroot` (uid 65532)
# from this point onwards.
#
# Explicitly clear the entrypoint inherited from cgr.dev/chainguard/python
# (which is `/usr/bin/python`) so `docker run image <cmd>` works portably.
# Absolute path to the venv python so we never depend on PATH ordering.
ENTRYPOINT []
CMD ["/home/projects/MagesticAI/.venv/bin/python", "-m", "server.main"]