Toolbox

Image build internals

Maintainer notes on how the runtime image is built: Dockerfile layout, layer ordering, version pinning, and the build-time decisions behind individual tools. User-facing image knobs (image, registry_mirror, pull) live in configuration.

Build layout: parallel fetch stages + frequency-ordered tail

The Dockerfile is structured for minimal rebuild time, not for linear readability:

Host UID mapping

The CLI runs the container with --user $(id -u):$(id -g). Because the runtime UID rarely matches the baked toolbox user (UID 1000), /home/toolbox is made world-writable in the image. Don't revert to a fixed UID without understanding why — host file ownership would invert and writes inside ~/.toolbox/ would fail for anyone whose host UID isn't 1000.

SSH host-key trust

The image bakes /etc/ssh/ssh_config.d/10-toolbox.conf (embedded asset ssh_config.toolbox, COPY --link'd in the final stage) with StrictHostKeyChecking accept-new and UserKnownHostsFile ~/.toolbox-state/known_hosts. This fixes the git-over-ssh deadlock: the ssh credential mount is a read-only symlink to the host (by design — keeps host ssh-keygen in sync), so the Debian default ask policy prompts on first contact and then can't persist the accepted key to the read-only ~/.ssh/known_hosts, re-prompting forever. accept-new removes the prompt while still rejecting changed keys; pointing known_hosts at the writable, persistent state mount lets keys persist across sessions. User-facing description in mounts.

The drop-in is loaded via Include /etc/ssh/ssh_config.d/*.conf in /etc/ssh/ssh_config. ssh uses the first-obtained value per keyword, so the Include must sit at the top of the base config (ahead of any built-in default) for accept-new to win — Debian's default already places it first, but a final-stage RUN re-asserts it idempotently rather than silently depending on upstream not moving it. Rejected alternatives: making the ssh mount writable (breaks the symlink host-sync design); boot-time ssh-keyscan seeding of common forges (adds a network dependency at boot plus a new init.d script → bijection/smoke churn, and accept-new already removes the prompt). The smoke test asserts the policy is effective via ssh -G rather than trusting the COPY landed.

Passwordless sudo

The base apt layer installs sudo, and the user-setup layer drops /etc/sudoers.d/toolbox with ALL ALL=(ALL:ALL) NOPASSWD: ALL (!requiretty, !fqdn). The runtime UID is the host's and rarely matches the baked toolbox user, so the rule is deliberately UID-agnostic — it matches whatever UID the entrypoint injects into /etc/passwd. This lets sudo apt-get update && sudo apt install … (or any root op) work inside a running container without baking the tool into the image (apt lists aren't baked, so update runs first). Safe because the container is AutoRemove (see Container teardown): everything installed at runtime vanishes on exit. Caveat: sudo writing into bind-mounted host paths (/workspace, ~/.toolbox/*) produces root:root files on the host — escalate for in-container/system state, not for editing mounted project files. visudo -cf validates the drop-in at build; the smoke test asserts the sudo binary is present and setuid root.

Docker CLI checksum

The fetch-docker stage of internal/build/assets/Dockerfile installs the static Docker CLI binary without a SHA256 verification step because Docker doesn't publish .sha256 files for those releases. Version pin + HTTPS is the only guard. Tracked as accepted risk T-01-08.

Tool version pinning

Every external binary in the Dockerfile is pinned by version, and Renovate bumps them. SHA256 verification is applied only when upstream publishes a checksums file: the fetch stage downloads it and pipes through sha256sum -c - (e.g. fetch-gh, fetch-helm, fetch-kubectl, fetch-rtk), which self-heals across bumps and re-tags. Tools whose upstream ships no checksums file (bat, fd, eza, zoxide, shellcheck, shfmt, Docker CLI, gcloud) download over HTTPS only. Hand-pinned per-arch SHA256 literals were removed: they broke the build on every version bump and even on an upstream re-tag of the same version (see zoxide 0.10.0), while providing no guarantee a self-authored hash could actually deliver. Adding a new tool is a 2-edit (or 3-edit when a runtime init script is needed) operation:

  1. New row in internal/catalog/catalog.go Entries.
  2. New install RUN block in internal/build/assets/Dockerfile.
  3. (optional) New init.d/<NN>-<tool>.sh if InitScript is set on the catalog row.

There is no per-tool opt-out: every CLI is installed unconditionally. The ARG INSTALL_<TOOL> build-arg pattern was removed (see #276). Use inherit_host_auth: in .toolbox.yaml to share host credentials with the container — see inherit-host-auth.

rtk arm64 is built from source

Dockerfile rtk-builder stage + final-stage COPY --link. Upstream only ships aarch64-unknown-linux-gnu linked against GLIBC 2.39, but the base image (node:24-bookworm-slim) ships GLIBC 2.36 — the prebuilt binary aborts with 'GLIBC_2.39' not found. There is no aarch64-unknown-linux-musl release.

Fix: multi-stage build. A rust:1-slim-bookworm stage runs cargo install --git rtk-ai/rtk --tag v${RTK_VERSION} --locked against the bookworm sysroot (so the resulting binary ABI-matches the runtime), version-checks it in-stage, and the final stage imports it with a single COPY --link --chmod=0755. The same stage handles the amd64 tarball download too.

The base image can't move to Debian trixie yet because the Microsoft Azure CLI apt repo currently has no trixie suite.

Rust base image tag scheme

<ver>-slim-<distro>, not <ver>-<distro>-slim. Docker Hub publishes rust:1-slim-bookworm (correct) but not rust:1-bookworm-slim (404). PR #89 hit this — the rtk-builder stage failed at image-pull time. When bumping or referencing the rust base, use <ver>-slim-<distro> (or bare <ver>-slim for the default trixie variant when we eventually move).

Slim Rust images ship no curl / ca-certificates

rust:1-slim-bookworm contains cargo + git but nothing to fetch tarballs with. The rtk-builder stage installs them via apt before the amd64 tarball path. If you copy the pattern for another tool (e.g. building a Rust binary from source), replicate the apt install — it doesn't propagate from the base.

Homebrew

Installed via shallow tag clone (ARG HOMEBREW_VERSION) at the default Linux prefix /home/linuxbrew/.linuxbrew — bottles (pre-built binaries) only work there; any other prefix forces source builds, explicitly unsupported upstream ("pick another prefix at your peril"). The official installer script is unusable in a Dockerfile RUN: it refuses root and clones unpinned main. The layer reproduces the installer's layout manually (repo at …/Homebrew + bin/brew symlink) and ships the pre-built _brew zsh completion from the clone.

Variable host UID handling follows the /home/toolbox pattern: chmod -R a+rwX /home/linuxbrew so any runtime UID can write the prefix, plus git config --system --add safe.directory /home/linuxbrew/.linuxbrew/Homebrew — the clone is root-owned and the runtime UID is arbitrary, so without it every git-touching brew op dies with "dubious ownership". System gitconfig (not --global) so the entry stays container-local and out of the user's host-synced ~/.gitconfig.

Runtime semantics:

PATH: image ENV prepends …/.linuxbrew/bin:…/.linuxbrew/sbin (covers non-interactive docker exec); interactive zsh additionally evals brew shellenv for HOMEBREW_PREFIX/MANPATH/INFOPATH (idempotent w.r.t. the ENV PATH entry). Private GitLab taps authenticate via the glab credential helper — see GitLab git credential helper (glab).

DO_NOT_TRACK + claude wrapper

Image sets ENV DO_NOT_TRACK=1 (consoledonottrack.com convention) — honored by bun, playwright, and most JS toolchains users run inside the container (next, astro, turbo, …). Claude Code honors it too, but as a telemetry umbrella: it also shuts down the Statsig channel that doubles as feature-flag delivery, which breaks Remote Control and preview rollouts (/doctor reports "Feature-flag evaluation enabled (disabled by DO_NOT_TRACK)"). Same failure mode as DISABLE_TELEMETRY — see the "Claude Code env knobs" comment block in the Dockerfile for why that flag is intentionally unset.

Fix: the claude install layer replaces the npm /usr/local/bin/claude symlink with a #!/bin/sh wrapper that does exec env -u DO_NOT_TRACK <real-cli> "$@" — the var is stripped for the claude process only, everything else in the container stays opted out. Don't "simplify" the wrapper back to a plain symlink; the smoke test (claude DO_NOT_TRACK wrapper) asserts the env -u line is present.

Known cost: children spawned by claude's Bash tool inherit the stripped environment, so JS tooling launched from inside a claude session loses the opt-out. Accepted trade-off — the alternative (no exemption) breaks Remote Control entirely.

Two Docker version streams

DOCKER_CLI_VERSION in the Dockerfile pins the CLI binary inside the container (currently 29.x); the SDK the CLI launcher uses lives in go.mod as the moby modules github.com/moby/moby/client (own v0.x series) + github.com/moby/moby/api (versioned after the Engine API, v1.x) — the deprecated github.com/docker/docker module is gone. The client negotiates the API version by default, so version drift between CLI binary, SDK, and daemon is expected and handled. Don't try to "align" them numerically.

Go version single source of truth

Unlike the two Docker streams, the Go version is deliberately aligned everywhere from one anchor: the toolchain directive in go.mod (e.g. toolchain go1.26.4). Renovate bumps toolchain by default (the go directive stays the lower compat floor and is not auto-bumped). Everything else derives:

So don't re-pin the Go version in the Dockerfile or add a Renovate manager for GO_VERSION / GO_IMAGE — bump toolchain in go.mod and it flows everywhere. Because the image's Go is derived from go.mod (not from a Dockerfile literal), docker-ci.yml also triggers on go.mod and includes it in the arm64 arch_coverage filter — a toolchain bump pulls a per-arch Go tarball (go${GO_VERSION}.linux-${TARGETARCH}.tar.gz), so both arches are smoke-tested in the PR.

golangci-lint follows the same one-literal rule: GOLANGCI_VERSION in the Makefile is the source (Renovate-bumped); the lint CI job reads it from the Makefile instead of carrying its own literal.

Tools removal

The tools: block in .toolbox.yaml and the ARG INSTALL_<TOOL> Dockerfile mechanism are removed (see #276). Every user runs the same canonical image (ghcr.io/filippolmt/toolbox:latest) — the per-tool opt-out, the local-hash image build, and the catalog-driven image hash are all gone.

If your config still has a tools: block, the loader emits a one-time warning and ignores it. Delete the block to silence the warning.

Node package weight prune

Each globally-installed npm tool layer (playwright-cli, codex, codegraph, …) ends with a find … -prune -exec rm -rf {} + that strips weight from node_modules: source maps, *.md / *.markdown, CHANGELOG*, AUTHORS*, and docs//examples//__tests__//.github/ dirs.

Gotcha — functional .md assets are collateral. Some packages ship runtime-required content as .md. playwright-cli is the live example: its agent skill ships as SKILL.md + references/*.md under playwright-core/lib/tools/cli-client/skill/ (and a @playwright/cli/skills/ copy). The blanket *.md prune empties those, so playwright-cli install --skills then copies an empty skill — the failure looks like a working install (exit 0, dirs created) but no SKILL.md. The playwright-cli prune therefore leads with -type d \( -path '*/cli/skills' -o -path '*/cli-client/skill' \) -prune -o … to spare both template trees, and the layer ends with a build-time test -s …/cli-client/skill/SKILL.md so a future prune/layout change that re-empties the source fails the build instead of shipping a silently-broken skill. The smoke test (playwright-cli skill install) re-checks it functionally at runtime. When adding a .md-pruning layer for a tool that bundles .md assets it actually uses, spare the asset dir the same way.

Renovate automerge

Updates land in two grouped PRs: runtime-image deps from the Dockerfile (matchFileNames: ["internal/build/assets/Dockerfile"] → group dockerfile image) and everything else (matchPackageNames: ["*"] → group all dependencies). The Dockerfile rule comes after the catch-all so its groupName wins for those deps; schedule/automerge are inherited from the catch-all. Both merge daily in the 06:00–09:59 Europe/Rome window, Renovate-side (platformAutomerge: false, automergeType: pr). Three deliberate choices: