# Kandev Universal Dockerfile # # Extends the vanilla kandev image with language toolchains and developer # tools so agents can build/test code in the most common stacks without # extra setup. Built and published alongside vanilla in the release # workflow. Inclusion policy: see docs/images.md. # # Build: # docker build -f Dockerfile.universal -t kandev:latest-universal . # # Run (drop-in replacement for the vanilla image): # docker run -p 38429:38429 -v kandev-data:/data ghcr.io/kdlbs/kandev:universal # # The BASE_IMAGE arg lets the release workflow point at a freshly built # vanilla image inside the same run (so universal-of-X.Y.Z is built on top # of vanilla-X.Y.Z, not on whatever :latest happens to be). ARG BASE_IMAGE=ghcr.io/kdlbs/kandev:latest FROM ${BASE_IMAGE} USER root # Pinned versions. Bump these deliberately; CI's size gate will catch growth. # # The Playwright Chromium apt deps below are hand-derived against Playwright # 1.48.0. When that target moves, the apt-get install line may need to # add/drop packages - check the upstream list at # https://github.com/microsoft/playwright/blob/v/packages/playwright-core/src/server/registry/nativeDeps.ts # or run `npx playwright@ install-deps --dry-run` to diff. (Tracked # only via this comment, not an ARG, since the apt package names are hardcoded # below and a build-arg override would silently disagree with what's installed.) ARG GO_VERSION=1.26.3 ARG GOLANGCI_LINT_VERSION=1.62.0 ARG PNPM_VERSION=9.15.9 # Single RUN to minimize image layers and clean apt lists in the same layer. # Tools grouped by purpose; comments explain the why for each cluster so # future maintainers can prune deliberately rather than guess. RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ # Tarball fetcher for the toolchain install steps below (Go, # golangci-lint, rustup, yq). Not provided by the vanilla base image. curl \ # Native compile (CGO, pip wheels with C extensions, native npm modules) build-essential \ pkg-config \ python3-dev \ libssl-dev \ # Useful CLI tools agents reach for. Small, broadly applicable. ripgrep \ fd-find \ jq \ # Playwright Chromium runtime deps. Browsers themselves are NOT # preinstalled - users run `pnpm exec playwright install chromium` # once and the download persists at ~/.cache/ms-playwright on the PV. # This list mirrors `npx playwright@1.48.0 install-deps chromium`. libnss3 \ libnspr4 \ libatk1.0-0 \ libatk-bridge2.0-0 \ libcups2 \ libdrm2 \ libxkbcommon0 \ libxcomposite1 \ libxdamage1 \ libxfixes3 \ libxrandr2 \ libgbm1 \ libpango-1.0-0 \ libcairo2 \ libasound2 \ fonts-liberation \ fonts-noto-color-emoji \ ; \ rm -rf /var/lib/apt/lists/* # Go toolchain - tarball install to /usr/local/go (the standard layout the # go.dev installer creates). Add to PATH below. # # Integrity: go.dev's `?mode=json&include=all` endpoint lists every released # version with a per-file `sha256` field. We fetch that index, pick the SHA # for our tarball, and verify before extraction. (The `.sha256` sidecar URLs # that look like they should work redirect to the HTML downloads page, so # they cannot be used here.) This is TOFU on first fetch; if go.dev itself # is compromised both the JSON and the tarball are bad. RUN set -eux; \ arch="$(dpkg --print-architecture)"; \ case "$arch" in \ amd64) goarch=amd64 ;; \ arm64) goarch=arm64 ;; \ *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ esac; \ tarball="go${GO_VERSION}.linux-${goarch}.tar.gz"; \ expected="$(curl -fsSL 'https://go.dev/dl/?mode=json&include=all' \ | jq -r --arg v "go${GO_VERSION}" --arg f "${tarball}" \ 'first(.[] | select(.version == $v) | .files[] | select(.filename == $f) | .sha256)')"; \ if [ -z "$expected" ]; then \ echo "no upstream sha256 for ${tarball} at version ${GO_VERSION}" >&2; \ exit 1; \ fi; \ curl -fsSL "https://go.dev/dl/${tarball}" -o /tmp/go.tar.gz; \ echo "${expected} /tmp/go.tar.gz" | sha256sum -c -; \ tar -C /usr/local -xzf /tmp/go.tar.gz; \ rm /tmp/go.tar.gz # golangci-lint - prebuilt binary, no compilation needed. # Integrity: each release publishes a single `*-checksums.txt` file with # ` ` lines for every artifact. Grep for our tarball, # pipe to `sha256sum -c -`. Download into /tmp and cd there so the relative # filename in the checksum line resolves. RUN set -eux; \ arch="$(dpkg --print-architecture)"; \ case "$arch" in \ amd64) lintarch=amd64 ;; \ arm64) lintarch=arm64 ;; \ *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ esac; \ tarball="golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${lintarch}.tar.gz"; \ base="https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}"; \ curl -fsSL "${base}/${tarball}" -o "/tmp/${tarball}"; \ curl -fsSL "${base}/golangci-lint-${GOLANGCI_LINT_VERSION}-checksums.txt" -o /tmp/golangci-lint-checksums.txt; \ (cd /tmp && grep " ${tarball}$" golangci-lint-checksums.txt | sha256sum -c -); \ tar -xz -C /tmp -f "/tmp/${tarball}"; \ mv "/tmp/golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${lintarch}/golangci-lint" /usr/local/bin/; \ rm -rf "/tmp/${tarball}" "/tmp/golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${lintarch}" /tmp/golangci-lint-checksums.txt # Rust toolchain via rustup. RUSTUP_HOME/CARGO_HOME live in /usr/local so the # install is system-wide and read-only for non-root users at runtime - # matching the rest of the image. Users who need writable per-user state # (e.g. `cargo install `, `rustup toolchain install nightly`) should # override CARGO_HOME and RUSTUP_HOME to a path on the PV, e.g.: # CARGO_HOME=~/.cargo RUSTUP_HOME=~/.rustup cargo install # # `--profile minimal` keeps the install lean (rustc + cargo + rust-std); we # then explicitly add clippy and rustfmt, which agents working on Rust # projects almost always reach for. `--profile default` would also work but # pulls in rust-docs which we don't need. # # Integrity: download rustup-init for our arch and verify its SHA-256 # against the upstream .sha256 sidecar before executing - matches the # pattern used for golangci-lint/yq above, rather than `curl | sh`. # (Go uses the JSON release index instead - go.dev doesn't actually # publish per-tarball .sha256 sidecars despite the URL pattern looking # like it would work.) ENV RUSTUP_HOME=/usr/local/rustup \ CARGO_HOME=/usr/local/cargo RUN set -eux; \ arch="$(dpkg --print-architecture)"; \ case "$arch" in \ amd64) triplet=x86_64-unknown-linux-gnu ;; \ arm64) triplet=aarch64-unknown-linux-gnu ;; \ *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ esac; \ base="https://static.rust-lang.org/rustup/dist/${triplet}"; \ curl -fsSL "${base}/rustup-init" -o /tmp/rustup-init; \ curl -fsSL "${base}/rustup-init.sha256" -o /tmp/rustup-init.sha256; \ (cd /tmp && sha256sum -c rustup-init.sha256); \ chmod +x /tmp/rustup-init; \ /tmp/rustup-init -y --default-toolchain stable --no-modify-path --profile minimal; \ /usr/local/cargo/bin/rustup component add --toolchain stable clippy rustfmt; \ rm /tmp/rustup-init /tmp/rustup-init.sha256; \ chmod -R a+rX "$RUSTUP_HOME" "$CARGO_HOME" # pnpm via npm - install system-wide so users don't need to bootstrap it # themselves. Drops shims into /usr/local/bin. # # No separate sha256 step: npm downloads from registry.npmjs.org and verifies # the tarball against the registry's `integrity` field (SHA-512) before # installing. The other tools above pin upstream `.sha256` sidecars because # they're served from CDNs that don't expose package integrity at the API level; # npm does. Decision is deliberate, not an oversight. RUN set -eux; \ npm install -g --prefix /usr/local "pnpm@${PNPM_VERSION}"; \ pnpm --version # fd is installed as `fdfind` on Debian to avoid clashing with the legacy # fd package - symlink so agents calling plain `fd` still work. RUN ln -s /usr/bin/fdfind /usr/local/bin/fd # yq is not in Debian stable; install the prebuilt binary from upstream # (Mike Farah's yq, the Go implementation - the one most scripts assume). # # Integrity: mikefarah/yq publishes a `checksums` file with one row per # artifact and multiple hash columns, plus a `checksums_hashes_order` file # that lists which hash algorithm corresponds to each column. We compute the # SHA-256 column index dynamically (defensive against upstream reordering) # and verify the binary against that column before installing. ARG YQ_VERSION=4.44.3 RUN set -eux; \ arch="$(dpkg --print-architecture)"; \ target="yq_linux_${arch}"; \ base="https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}"; \ curl -fsSL "${base}/checksums" -o /tmp/yq-checksums; \ curl -fsSL "${base}/checksums_hashes_order" -o /tmp/yq-hashes-order; \ sha256_col="$(awk '/^SHA-256$/ {print NR + 1; exit}' /tmp/yq-hashes-order)"; \ if [ -z "$sha256_col" ]; then echo "SHA-256 not in upstream checksums_hashes_order" >&2; exit 1; fi; \ expected="$(awk -v t="$target" -v c="$sha256_col" '$1==t {print $c}' /tmp/yq-checksums)"; \ if [ -z "$expected" ]; then echo "no checksum row for $target" >&2; exit 1; fi; \ curl -fsSL "${base}/${target}" -o /tmp/yq; \ echo "${expected} /tmp/yq" | sha256sum -c -; \ mv /tmp/yq /usr/local/bin/yq; \ chmod +x /usr/local/bin/yq; \ rm /tmp/yq-checksums /tmp/yq-hashes-order # Append Go's toolchain bin and cargo's bin to whatever PATH the base image # set. Appending rather than hardcoding the full list keeps the agent CLI # dir (/data/.npm-global/bin) first - as the base image already arranges - # and stays correct if the base image's PATH ever changes. # # Note: $GOPATH/bin (defaults to ~/go/bin, i.e. /data/home/go/bin in this # image) is NOT added here - it's user-specific and lives on the PV. Users # who `go install` tools and want them on PATH should add it to their shell # profile (e.g. `export PATH=$PATH:$(go env GOPATH)/bin`). ENV PATH=$PATH:/usr/local/go/bin:/usr/local/cargo/bin # Drop back to the unprivileged user. Matches the vanilla image's runtime # user; the docker-entrypoint.sh in the base image handles root → kandev # privilege drop via gosu for users who run the container as root. USER kandev