mirror of
https://github.com/hanzoai/bot.git
synced 2026-10-30 17:24:26 +00:00
1153 lines
63 KiB
Docker
1153 lines
63 KiB
Docker
# syntax=docker/dockerfile:1
|
|
#
|
|
# The sandbox image: the toolchain a coding agent actually works in.
|
|
#
|
|
# Published as oci.hanzo.ai/hanzoai/sandbox — the repo hanzoai/cloud's
|
|
# apps/sandbox/runtime.go names by default (SANDBOX_IMAGE_REPO). The FILE is
|
|
# still Dockerfile.box because `Dockerfile.sandbox` in this repo is TAKEN, by a
|
|
# live and unrelated feature: bot's own Docker sandbox
|
|
# (src/agents/sandbox/constants.ts pins `bot-sandbox-common:bookworm-slim`,
|
|
# scripts/sandbox-common-setup.sh builds it, doctor-sandbox.ts checks it).
|
|
# "box" is this repo's local word for the artifact; `hanzoai/sandbox` is the
|
|
# name the fleet pulls it by, and the registry path is the one that has to
|
|
# match a consumer.
|
|
#
|
|
# ONE recipe, four published tags, one chain that forks once:
|
|
#
|
|
# exec ──▶ dev ──┬──▶ desktop
|
|
# └──▶ admin
|
|
#
|
|
# Three of them are a CLASS in apps/sandbox/runtime.go's imageFor() — the word a
|
|
# caller asks for, the pod label and the tag, all one vocabulary.
|
|
#
|
|
# `admin` IS A TAG AND NOT A CLASS, and that difference is the whole point of it.
|
|
# Nobody asks for it: a SuperAdmin's `dev` sandbox runs it and everybody else's
|
|
# `dev` sandbox runs `dev`. Which bytes you get is a fact about WHO IS ASKING,
|
|
# and a fact about the asker has no business being a word in the request — a
|
|
# fourth class would be a field every caller could set, which is the opposite of
|
|
# what this image is for.
|
|
#
|
|
# WHERE THE THREE ARE CUT, AND WHY IT IS NOT WHERE YOU WOULD GUESS.
|
|
# Not on disk. Image layers are shared per NODE, so `exec` being a strict layer
|
|
# subset of `dev` means a node that has ever pulled `dev` already has every byte
|
|
# `exec` needs — shrinking `exec` by moving compilers out of it would save
|
|
# nothing on that node and cost a second, unshared toolchain layer everywhere
|
|
# else. The cut is on RUNTIME cost, which is the thing that is actually paid per
|
|
# pod:
|
|
#
|
|
# exec full toolchain, a headless browser AND the three Hanzo binaries
|
|
# (hanzo, dev, hanzo-mcp), `sleep infinity`, nothing running.
|
|
# dev the same, plus the THIRD-PARTY agent CLIs `tool` can name — claude
|
|
# and codex — and gh. Still nothing running.
|
|
# desktop plus Xvfb + a window manager + a VNC server + noVNC — four
|
|
# processes on EVERY desktop pod, whether or not anyone looks at the
|
|
# screen. That is the only difference here that costs at runtime, so
|
|
# it is the only place a class boundary earns its keep.
|
|
# admin plus kubectl and doctl. Still nothing running, and still
|
|
# nothing PRIVILEGED: three binaries with no credential between them.
|
|
# It is a separate tag rather than three more packages in `dev`
|
|
# because a fleet client on every tenant's box is a tool nobody there
|
|
# asked for, not because the tools are dangerous on their own.
|
|
#
|
|
# THE BROWSER IS IN `exec`, NOT IN `desktop`, and that follows from the same
|
|
# rule. `desktop` does not ADD a browser; it adds a SCREEN for the browser that
|
|
# is already there. Headless works in all three classes; headful works where
|
|
# there is an X server to draw on. One browser, one install, two ways to run it.
|
|
#
|
|
# THERE IS NO DAEMON IN THIS IMAGE AND THERE MUST NEVER BE ONE AGAIN.
|
|
# The command is `sleep infinity`; work arrives through the Kubernetes exec
|
|
# subresource. The previous design shipped `cmd/boxd` here and had the cloud
|
|
# talk to it over HTTP — that daemon was deleted from hanzoai/cloud (commit
|
|
# cab8ff9d, "there is no box daemon, so the build stops trying to compile one")
|
|
# along with the ~2000 lines that served it, and this file's `COPY --from=cloud
|
|
# /boxd` went with it. The desktop stage starts X, which is a DISPLAY, not a
|
|
# control plane: nothing in this image accepts a command from outside.
|
|
#
|
|
# HOW CI SELECTS A STAGE. hanzoai/ci's image lane has no `target:` field — it
|
|
# runs `docker buildx build ... -f "$df" "$ctx"`, which builds the LAST stage.
|
|
# So the last stage is `FROM ${STAGE}`, an alias, and hanzo.yml picks the class
|
|
# with `args: {STAGE: exec|dev|desktop}`. BuildKit only builds the stages the
|
|
# target depends on, so `STAGE=exec` never pulls the X stack.
|
|
#
|
|
# EVERY THIRD-PARTY INPUT IS PINNED BY DIGEST OR CHECKSUM, AND EVERY VERSION IS
|
|
# A LITERAL. A floating tag means two builds of one commit are two different
|
|
# images, and this lane pushes on pull_request too — so a mutable name here is a
|
|
# PR silently redefining what main builds on. Base images carry an @sha256;
|
|
# tarballs carry the publisher's SHA-256, verified before anything is unpacked.
|
|
# Every number below was read from the publisher on 2026-08-06, not remembered.
|
|
|
|
ARG STAGE=desktop
|
|
|
|
# Ubuntu 26.04 LTS "Resolute Raccoon". Same base hanzoai/operative moved its
|
|
# xvfb image to, so the two computer-use images share a userland instead of
|
|
# drifting apart on two distros.
|
|
ARG UBUNTU_BASE=docker.io/library/ubuntu:26.04@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03
|
|
|
|
# uv is the ONE Python installer/venv manager we use. Taken as a static binary
|
|
# out of the upstream image rather than piped from an install script: an image
|
|
# is digest-pinnable and a shell script is not.
|
|
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.12.2@sha256:069a51314a7bb6031777a9273205fe1b0b19e914ef418207d1338b268df641dd
|
|
|
|
FROM ${UV_IMAGE} AS uvsrc
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# exec — the full toolchain. Also the class that runs a snippet and is thrown
|
|
# away, which is why it is the BOTTOM of the chain rather than a fourth "base"
|
|
# tag that nothing ever runs.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
FROM ${UBUNTU_BASE} AS exec
|
|
|
|
ARG TARGETARCH
|
|
|
|
ENV DEBIAN_FRONTEND=noninteractive \
|
|
LANG=C.UTF-8 \
|
|
LC_ALL=C.UTF-8 \
|
|
TZ=UTC \
|
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
|
PIP_ROOT_USER_ACTION=ignore \
|
|
NPM_CONFIG_FUND=false \
|
|
NPM_CONFIG_AUDIT=false \
|
|
NPM_CONFIG_UPDATE_NOTIFIER=false
|
|
|
|
# The things a coding agent reaches for, plus the headers a native build needs.
|
|
# Ubuntu 26.04 ships python3 3.14.3, ruby 3.3 and php 8.5 — current stable of
|
|
# all three — so those three languages come from apt and stay on the distro's
|
|
# security-update track instead of being frozen into a hand-fetched tarball.
|
|
RUN apt-get update \
|
|
&& apt-get install -y --no-install-recommends \
|
|
autoconf automake bash bat bc build-essential ca-certificates cmake curl \
|
|
dnsutils fd-find file fzf gettext git git-lfs gnupg gpg htop iproute2 \
|
|
iputils-ping jq less libbz2-dev libffi-dev libncurses-dev libreadline-dev \
|
|
liblzma-dev libsqlite3-dev libssl-dev libxml2-dev libxslt1-dev libyaml-dev \
|
|
libzip-dev locales make man-db ncdu netcat-openbsd ninja-build openssh-client \
|
|
patch pkg-config procps psmisc python3 python3-dev python3-venv rsync \
|
|
ripgrep socat sqlite3 strace tar tini tmux tree tzdata unzip vim wget \
|
|
xz-utils zip zlib1g-dev zstd \
|
|
php php-cli php-common php-curl php-mbstring php-mysql php-xml php-zip composer \
|
|
ruby ruby-dev \
|
|
&& ln -sf /usr/share/zoneinfo/UTC /etc/localtime \
|
|
&& ln -sf /usr/bin/fdfind /usr/local/bin/fd \
|
|
&& ln -sf /usr/bin/batcat /usr/local/bin/bat \
|
|
&& ln -sf /usr/bin/python3 /usr/local/bin/python \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# ---- Node.js ---------------------------------------------------------------
|
|
# 24.19.0 is the ACTIVE LTS line ("Krypton"). Node 26.7.0 is newer and is NOT
|
|
# LTS until October, and a sandbox is where other people's projects build — the
|
|
# line their CI targets is the one to ship. Taken as the official tarball rather
|
|
# than NodeSource because `setup_24.x` resolves to whatever 24.x is current on
|
|
# build day, which is precisely the unreproducibility this file is avoiding.
|
|
ARG NODE_VERSION=24.19.0
|
|
ARG NODE_SHA256_AMD64=14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647
|
|
ARG NODE_SHA256_ARM64=01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc
|
|
# pnpm 11.20.0 is current, installed with npm -g.
|
|
#
|
|
# DELIBERATELY NOT `corepack enable`, for two reasons that both bite in a
|
|
# sandbox. It would collide: corepack's shims and `npm -g` both claim
|
|
# /usr/local/bin/pnpm and /usr/local/bin/yarn, and the second one to run either
|
|
# fails EEXIST or silently wins. And a corepack shim resolves the package
|
|
# manager by DOWNLOADING it on first use, so the first `pnpm install` in a pod
|
|
# with restricted egress fails on the package manager rather than the packages.
|
|
# A sandbox has to work with the network turned down. Projects that pin
|
|
# `packageManager:` can still opt in explicitly with `corepack use`.
|
|
ARG PNPM_VERSION=11.20.0
|
|
RUN set -eux; \
|
|
case "${TARGETARCH}" in \
|
|
amd64) narch=x64; nsha="${NODE_SHA256_AMD64}" ;; \
|
|
arm64) narch=arm64; nsha="${NODE_SHA256_ARM64}" ;; \
|
|
*) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
|
|
esac; \
|
|
curl -fsSLo /tmp/node.tar.xz "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${narch}.tar.xz"; \
|
|
echo "${nsha} /tmp/node.tar.xz" | sha256sum -c -; \
|
|
tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 --no-same-owner \
|
|
--exclude='*/CHANGELOG.md' --exclude='*/README.md' --exclude='*/LICENSE'; \
|
|
rm -f /tmp/node.tar.xz; \
|
|
npm install -g "pnpm@${PNPM_VERSION}" yarn typescript; \
|
|
npm cache clean --force; \
|
|
rm -rf /root/.npm; \
|
|
node --version; npm --version; pnpm --version
|
|
|
|
# ---- Python: uv ------------------------------------------------------------
|
|
# The interpreter is the distro's 3.14.3 (above); uv is how anything gets
|
|
# installed into it. `pip` stays available through `uv pip` and python3-venv.
|
|
#
|
|
# SOURCE PATH IS `/uv`, NOT `/usr/local/bin/uv`. The plain `uv:<version>` tag is
|
|
# DISTROLESS and ships both binaries at the root — that is the layout upstream's
|
|
# own copy line documents. The Debian-flavoured tags (uv:python3.12-bookworm and
|
|
# friends) are the ones with /usr/local/bin, and borrowing their path here is
|
|
# what made every build of this file die at "/usr/local/bin/uvx: not found"
|
|
# before it reached a single toolchain. Verified against the pinned digest, not
|
|
# remembered: `/uv --version` and `/uvx --version` both answer 0.12.2, and
|
|
# `/usr/local/bin/uv` does not stat.
|
|
COPY --from=uvsrc /uv /uvx /usr/local/bin/
|
|
|
|
# ---- Go --------------------------------------------------------------------
|
|
ARG GO_VERSION=1.26.8
|
|
ARG GO_SHA256_AMD64=d0f743b33e8d8945e6b1f432edd15785c70507121d6e2a723b21285eddf8b57b
|
|
ARG GO_SHA256_ARM64=211ffced9dcb9633a55eac6364816ec0ddd951389a740e88fa8b3337971bdda0
|
|
RUN set -eux; \
|
|
case "${TARGETARCH}" in \
|
|
amd64) gsha="${GO_SHA256_AMD64}" ;; \
|
|
arm64) gsha="${GO_SHA256_ARM64}" ;; \
|
|
*) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
|
|
esac; \
|
|
curl -fsSLo /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz"; \
|
|
echo "${gsha} /tmp/go.tar.gz" | sha256sum -c -; \
|
|
tar -C /usr/local -xzf /tmp/go.tar.gz; \
|
|
rm -f /tmp/go.tar.gz; \
|
|
/usr/local/go/bin/go version
|
|
# THE USER'S OWN bin IS FIRST, and that ordering is what lets an agent CLI
|
|
# update itself. npm's per-user prefix installs into ~/.local/bin (see the dev
|
|
# stage) and `claude install latest` writes the self-updating binary there; a
|
|
# copy under /usr/local/bin earlier on PATH would shadow every update with the
|
|
# version frozen into a layer. Written out rather than $HOME because ENV is
|
|
# evaluated while this stage still runs as root.
|
|
ENV PATH=/home/sandbox/.local/bin:/usr/local/go/bin:/usr/local/cargo/bin:$PATH \
|
|
GOPATH=/home/sandbox/go \
|
|
GOTOOLCHAIN=auto
|
|
|
|
# ---- Rust ------------------------------------------------------------------
|
|
# System-wide RUSTUP_HOME/CARGO_HOME so the toolchain is a shared read-only
|
|
# layer rather than a per-user copy in a home directory that a PVC will shadow.
|
|
ARG RUST_VERSION=1.97.1
|
|
ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo
|
|
RUN set -eux; \
|
|
curl -fsSL --proto '=https' --tlsv1.2 https://sh.rustup.rs -o /tmp/rustup.sh; \
|
|
sh /tmp/rustup.sh -y --no-modify-path --profile minimal \
|
|
--default-toolchain "${RUST_VERSION}" --component rustfmt --component clippy; \
|
|
rm -f /tmp/rustup.sh; \
|
|
chmod -R a+rX "${RUSTUP_HOME}" "${CARGO_HOME}"; \
|
|
rustc --version; cargo --version
|
|
# The INSTALL went to /usr/local, world-READABLE (a+rX, not a+w — the official
|
|
# rust images use a+w, but this one runs submitted code and a toolchain every
|
|
# process can rewrite is a toolchain the next process cannot trust). Cargo still
|
|
# needs somewhere to write its registry and build cache, so CARGO_HOME moves to
|
|
# the user's home for RUNTIME while the binaries stay on PATH at
|
|
# /usr/local/cargo/bin. Read-only shared layer, writable per-user cache.
|
|
ENV CARGO_HOME=/home/sandbox/.cargo
|
|
|
|
# ---- Bun -------------------------------------------------------------------
|
|
ARG BUN_VERSION=1.3.14
|
|
ARG BUN_SHA256_AMD64=951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f
|
|
ARG BUN_SHA256_ARM64=a27ffb63a8310375836e0d6f668ae17fa8d8d18b88c37c821c65331973a19a3b
|
|
RUN set -eux; \
|
|
case "${TARGETARCH}" in \
|
|
amd64) barch=x64; bsha="${BUN_SHA256_AMD64}" ;; \
|
|
arm64) barch=aarch64; bsha="${BUN_SHA256_ARM64}" ;; \
|
|
esac; \
|
|
curl -fsSLo /tmp/bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-${barch}.zip"; \
|
|
echo "${bsha} /tmp/bun.zip" | sha256sum -c -; \
|
|
unzip -q /tmp/bun.zip -d /tmp/bun; \
|
|
install -m 0755 "/tmp/bun/bun-linux-${barch}/bun" /usr/local/bin/bun; \
|
|
ln -sf /usr/local/bin/bun /usr/local/bin/bunx; \
|
|
rm -rf /tmp/bun /tmp/bun.zip; \
|
|
bun --version
|
|
|
|
# ---- Deno ------------------------------------------------------------------
|
|
ARG DENO_VERSION=2.9.5
|
|
ARG DENO_SHA256_AMD64=8b010a3b1a4a0188a67cdb8a7a27348b2a501af78aec7fc74f2ace167368d530
|
|
ARG DENO_SHA256_ARM64=6b7cae3a8fc4385a59dea3146fcb8bad7fea4230e0ad36a8c692afacbc254be0
|
|
RUN set -eux; \
|
|
case "${TARGETARCH}" in \
|
|
amd64) darch=x86_64; dsha="${DENO_SHA256_AMD64}" ;; \
|
|
arm64) darch=aarch64; dsha="${DENO_SHA256_ARM64}" ;; \
|
|
esac; \
|
|
curl -fsSLo /tmp/deno.zip "https://github.com/denoland/deno/releases/download/v${DENO_VERSION}/deno-${darch}-unknown-linux-gnu.zip"; \
|
|
echo "${dsha} /tmp/deno.zip" | sha256sum -c -; \
|
|
unzip -q /tmp/deno.zip -d /tmp/deno; \
|
|
install -m 0755 /tmp/deno/deno /usr/local/bin/deno; \
|
|
rm -rf /tmp/deno /tmp/deno.zip; \
|
|
deno --version
|
|
|
|
# ---- Zig -------------------------------------------------------------------
|
|
ARG ZIG_VERSION=0.16.0
|
|
ARG ZIG_SHA256_AMD64=70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00
|
|
ARG ZIG_SHA256_ARM64=ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17
|
|
RUN set -eux; \
|
|
case "${TARGETARCH}" in \
|
|
amd64) zarch=x86_64; zsha="${ZIG_SHA256_AMD64}" ;; \
|
|
arm64) zarch=aarch64; zsha="${ZIG_SHA256_ARM64}" ;; \
|
|
esac; \
|
|
curl -fsSLo /tmp/zig.tar.xz "https://ziglang.org/download/${ZIG_VERSION}/zig-${zarch}-linux-${ZIG_VERSION}.tar.xz"; \
|
|
echo "${zsha} /tmp/zig.tar.xz" | sha256sum -c -; \
|
|
mkdir -p /opt/zig; \
|
|
tar -xJf /tmp/zig.tar.xz -C /opt/zig --strip-components=1; \
|
|
rm -f /tmp/zig.tar.xz; \
|
|
ln -sf /opt/zig/zig /usr/local/bin/zig; \
|
|
zig version
|
|
|
|
# ---- Java ------------------------------------------------------------------
|
|
# JDK 25 is the current LTS (Sept 2025) and is what Ubuntu 26.04 ships as
|
|
# openjdk-25-jdk 25.0.3. hanzoai/operative's image is still on 21; this is the
|
|
# newer LTS, and both are in the archive if a project pins the older one.
|
|
RUN apt-get update \
|
|
&& apt-get install -y --no-install-recommends openjdk-25-jdk-headless maven \
|
|
&& rm -rf /var/lib/apt/lists/* \
|
|
&& javac --version && java --version && mvn --version | head -1
|
|
|
|
# ---- Ruby / PHP finishing --------------------------------------------------
|
|
# The interpreters came from apt above; these are the package managers a
|
|
# checkout expects to already be there.
|
|
RUN set -eux; \
|
|
gem install --no-document bundler; \
|
|
ruby --version; bundler --version; php --version | head -1; composer --version
|
|
|
|
# ---- Browser ---------------------------------------------------------------
|
|
# A SANDBOX IS A COMPUTER, AND A COMPUTER HAS A SHELL — this one, in `exec`, so
|
|
# all three classes have it. It started out in `admin`, beside kubectl and doctl,
|
|
# and that was a shell filed under administration.
|
|
#
|
|
# The terminal has asked for it in every class all along: cloud's shell command
|
|
# is `exec zsh -l || exec bash -l || exec sh -l`, so a `dev` sandbox answered the
|
|
# first line by falling past it, and the prompt a person got was not the prompt
|
|
# the product asks for. A class is cut on RUNTIME cost (see the header) and an
|
|
# uninvoked shell costs nothing — it is disk, and layers are shared per node.
|
|
# The same argument the browser below is here on.
|
|
#
|
|
# From apt, on the distro's security-update track, for the same reason python,
|
|
# ruby and php are. tmux is already here: one multiplexer, one shell, installed
|
|
# once, and a terminal in ANY class can name a session and get its own prompt.
|
|
RUN apt-get update \
|
|
&& apt-get install -y --no-install-recommends zsh \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# Its config is installed further down, as the user who reads it — see the
|
|
# ellipsis block after `USER sandbox`. Nothing lands in /etc/zsh, because the
|
|
# dotfiles set no_global_rcs and zsh would skip it.
|
|
|
|
# INSTALLING IT IS NOT GETTING IT. The user's LOGIN SHELL is set where the user
|
|
# is created, further down, and it stayed /bin/bash after this — so zsh was on
|
|
# the disk of every class and was the prompt of none. tmux asks passwd what to
|
|
# run and gets bash; cloud's `exec zsh -l || exec bash -l` fallback never runs,
|
|
# because tmux is present in every class and takes over before it. One fact,
|
|
# read by everything: passwd says zsh, and tmux, `su -` and the fallback all
|
|
# agree without a second setting anywhere.
|
|
|
|
# ---- Browser ---------------------------------------------------------------
|
|
# A SANDBOX IS A COMPUTER, AND A COMPUTER HAS A BROWSER. This sits in `exec`,
|
|
# the BOTTOM of the chain, so all three classes have one — not in `desktop`,
|
|
# where it started out.
|
|
#
|
|
# The move is the file's own rule applied honestly. The class boundary is cut on
|
|
# RUNTIME cost (see the header): `desktop` earns its tag because Xvfb, openbox,
|
|
# x0vncserver and websockify are four processes running on every desktop pod
|
|
# forever. A browser BINARY that nobody launched costs nothing at runtime — it
|
|
# is disk, and image layers are shared per node, so the node that pulled
|
|
# `desktop` already had these bytes. Keeping the browser up in `desktop` did not
|
|
# save `dev` anything; it just meant the DEFAULT class for an agent run could
|
|
# not open a web page. Measured on the built images before this change: `dev`
|
|
# had no browser at all and no way to install one without egress.
|
|
#
|
|
# THE npm PACKAGE, NOT JUST THE BROWSER, AND THAT IS THE HALF THAT WAS MISSING.
|
|
# `npx playwright install` downloads the chromium BUILD to
|
|
# PLAYWRIGHT_BROWSERS_PATH and leaves the LIBRARY in an npx cache that the next
|
|
# line deletes. That is what the desktop stage did, and the result was a real
|
|
# chromium at /opt/playwright that nothing in the image could drive:
|
|
# `require("playwright")` threw MODULE_NOT_FOUND. Installing the package
|
|
# globally and putting the global root on NODE_PATH means `require("playwright")`
|
|
# resolves from ANY cwd with the network turned all the way down — which is the
|
|
# state a confined sandbox is supposed to run in.
|
|
#
|
|
# ONE browser for the whole fleet: this is the same pinned build our e2e suites
|
|
# drive, so a computer-use run and a test run look at one chromium. Installed to
|
|
# a system path so it is a shared read-only layer a mounted /work PVC cannot
|
|
# shadow. Chromium is NOT available from apt on Ubuntu — `chromium` is absent
|
|
# from the archive and `chromium-browser` is a transitional stub that installs a
|
|
# snap, and there is no snapd in a container.
|
|
ARG PLAYWRIGHT_VERSION=1.62.1
|
|
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright \
|
|
NODE_PATH=/usr/local/lib/node_modules
|
|
RUN set -eux; \
|
|
npm install -g "playwright@${PLAYWRIGHT_VERSION}"; \
|
|
playwright install --with-deps chromium; \
|
|
chmod -R a+rX "${PLAYWRIGHT_BROWSERS_PATH}"; \
|
|
npm cache clean --force; \
|
|
rm -rf /root/.npm /var/lib/apt/lists/*; \
|
|
test -n "$(find "${PLAYWRIGHT_BROWSERS_PATH}" -type f -name chrome -print -quit)"; \
|
|
node -e 'require("playwright"); console.log("playwright " + require("playwright/package.json").version + " resolvable from " + process.cwd())'
|
|
|
|
# THE BROWSER GETS A NAME. Playwright unpacks chromium to a build-numbered path
|
|
# — /opt/playwright/chromium-1234/chrome-linux64/chrome — which a library resolves
|
|
# and a human never types. So an agent that shells out to `chromium` got "command
|
|
# not found" for a browser that was sitting right there, three directories deep,
|
|
# on a machine whose whole job is to be a computer.
|
|
#
|
|
# ONE symlink is the single fact; the three env vars are just its other spellings.
|
|
# `chromium` and `chromium-browser` are what a shell command reaches for,
|
|
# `google-chrome` is what a handful of tools hardcode, and CHROME_BIN /
|
|
# CHROME_PATH / PUPPETEER_EXECUTABLE_PATH are three real consumers (karma,
|
|
# chrome-launcher and lighthouse, puppeteer) asking the same question with three
|
|
# different names. All five point at /usr/local/bin/chromium and none of them
|
|
# repeats the versioned path — so a playwright bump moves the target and nothing
|
|
# downstream needs editing.
|
|
#
|
|
# A SYMLINK IS ENOUGH, and that is not luck: chromium locates its own resource
|
|
# bundle from the realpath of /proc/self/exe, so it finds chrome-linux64 through
|
|
# the link. The `--version` below is the proof, run at build time.
|
|
RUN set -eux; \
|
|
chrome="$(find "${PLAYWRIGHT_BROWSERS_PATH}" -type f -name chrome -perm -u+x -print -quit)"; \
|
|
[ -n "$chrome" ]; \
|
|
ln -sf "$chrome" /usr/local/bin/chromium; \
|
|
ln -sf /usr/local/bin/chromium /usr/local/bin/chromium-browser; \
|
|
ln -sf /usr/local/bin/chromium /usr/local/bin/google-chrome; \
|
|
chromium --version
|
|
ENV CHROME_BIN=/usr/local/bin/chromium \
|
|
CHROME_PATH=/usr/local/bin/chromium \
|
|
PUPPETEER_EXECUTABLE_PATH=/usr/local/bin/chromium
|
|
|
|
# uid/gid 1000, named `sandbox`. The pod runs runAsNonRoot with runAsUser 1000,
|
|
# so the uid is fixed by the securityContext and only the NAME is a choice here.
|
|
#
|
|
# THE BASE ALREADY OWNS 1000. Ubuntu's image has shipped a default `ubuntu`
|
|
# user at uid/gid 1000 since 24.04, so a bare `groupadd -g 1000` exits 4
|
|
# ("GID 1000 already exists") and takes the build with it. Whatever holds 1000
|
|
# is removed first — by ID, not by the name `ubuntu`, so this keeps working if
|
|
# the base renames it. Claiming a free uid instead would be worse than a broken
|
|
# build: the pod's securityContext pins runAsUser 1000, so the agent would run
|
|
# as a uid with no passwd entry, no home and no ownership of /work.
|
|
RUN set -eux; \
|
|
if u=$(getent passwd 1000 | cut -d: -f1) && [ -n "$u" ]; then userdel -r "$u" 2>/dev/null || userdel "$u"; fi; \
|
|
if g=$(getent group 1000 | cut -d: -f1) && [ -n "$g" ]; then groupdel "$g" || true; fi; \
|
|
groupadd -g 1000 sandbox; \
|
|
useradd -u 1000 -g 1000 -m -s /usr/bin/zsh -d /home/sandbox sandbox; \
|
|
id sandbox; \
|
|
[ "$(getent passwd sandbox | cut -d: -f7)" = "$(command -v zsh)" ]
|
|
|
|
# /work is where the per-(org,project) PVC mounts — the checkout and the caches.
|
|
# Created here so an empty volume inherits the right owner instead of arriving
|
|
# root-owned and read-only to the agent.
|
|
RUN mkdir -p /work /home/sandbox/go /home/sandbox/.cargo \
|
|
&& chown -R sandbox:sandbox /work /home/sandbox
|
|
|
|
# ---- The three Hanzo binaries ----------------------------------------------
|
|
# THEY LIVE IN `exec`, WHICH IS THE CLASS EVERY CALLER GETS BY DEFAULT.
|
|
# hanzoai/cloud apps/sandbox/api.go defaults an unnamed class to `exec`, and
|
|
# these were in `dev` — so the sandbox a caller actually got had no agent in it.
|
|
# Measured in a live production pod: `command -v hanzo`, `dev` and `hanzo-mcp`
|
|
# were all empty, while node, go, rustc, bun, deno and zig were all present. A
|
|
# box with six compilers and no agent is not an agentic sandbox.
|
|
#
|
|
# THE OTHER REPAIR — defaulting the class to `dev` — COSTS 8x MORE AND IS WRONG.
|
|
# `exec` is not the small fast class; nothing here is small. Measured from
|
|
# kubelet's own image accounting on the live nodes:
|
|
#
|
|
# exec 1,509,851,245 B dev 1,791,506,510 B delta 281,655,265 B
|
|
#
|
|
# Of that 282 MB delta, these three binaries are 36 MB — 4.7 MB hanzo,
|
|
# 24.9 MB dev, 6.6 MB hanzo-mcp, as published (linux-amd64, already gzipped).
|
|
# The other 246 MB is claude-code and codex, which unpack to 290 MB and 366 MB
|
|
# and are third-party CLIs the `tool` enum names. So putting OUR agent in the
|
|
# default class costs +2.4% on a 1.5 GB image, and moving the default to `dev`
|
|
# would have made every short function invoke pull the two npm agents as well.
|
|
# The cheap half of the delta is the half that makes the box agentic; the
|
|
# expensive half stays in `dev`, where a caller asks for it by name.
|
|
#
|
|
# It is also the LAST layer of `exec` on purpose: the agents version far faster
|
|
# than the toolchain, so an agent bump rebuilds and re-pulls ~36 MB instead of
|
|
# invalidating the 1.5 GB of compilers beneath it.
|
|
#
|
|
# THREE PRODUCTS, THREE BINARIES, ONE FETCH RULE. They are not three spellings
|
|
# of one thing:
|
|
#
|
|
# hanzo the cloud CLI — auth, billing, and every product's operations,
|
|
# generated from the same contract the API and SDKs serve.
|
|
# dev the coding agent. `dev exec …` is literally what the runner's
|
|
# TOOLS table spells (src/gateway/coding-task.ts).
|
|
# hanzo-mcp the MCP server — 13 HIP-0300 tools over JSON-RPC, which is how an
|
|
# agent gets tools instead of a shell and a hope.
|
|
#
|
|
# WHAT THIS REPLACES, AND WHY THE OLD SHAPE WAS WRONG THREE WAYS. `npm install -g
|
|
# @hanzo/dev` put a Node shim at /usr/local/bin/dev that spawns a native binary
|
|
# nested in its own node_modules — so (a) launching the agent needed a working
|
|
# node, (b) that package also claims the name `hanzo`, which is how the CLOUD CLI
|
|
# came to be a symlink to the AGENT and no `hanzo` command existed at all, and
|
|
# (c) the MCP server has no npm package on that path, so it was simply absent.
|
|
# Measured in a live pod before this change: `hanzo` and `dev` were the same
|
|
# dev.js, and `command -v hanzo-mcp` was empty.
|
|
#
|
|
# ONE FETCH RULE, WHICH IS THE PUBLISHER'S OWN CONVENTION (hanzoai/cli
|
|
# install.sh): every published Hanzo binary is a GitHub release asset named
|
|
# <BIN>-<os>-<arch>.tar.gz that unpacks to a single file named <BIN>. So this is
|
|
# one loop over (repo, tag, bin), not three recipes — the fourth Hanzo binary
|
|
# costs one line.
|
|
#
|
|
# CHECKSUMS ARE LITERALS HERE, not fetched from the .sha256 beside the asset.
|
|
# A digest downloaded from the same host as the artifact proves the transport,
|
|
# not the bytes we agreed to ship; and a GitHub release asset CAN be replaced
|
|
# under a tag that does not move. These were read from the publisher on
|
|
# 2026-08-06 and are what makes two builds of this commit the same image.
|
|
#
|
|
# THE CLI IS PINNED AT 1.9.37, NOT AT LATEST, AND THAT IS A GAP NOT A CHOICE.
|
|
# hanzoai/cli's newest release is v1.9.40, and its release matrix declares a
|
|
# linux-amd64 target — but v1.9.40 published only darwin-amd64, darwin-arm64,
|
|
# linux-arm64 and windows-amd64. There is no linux-amd64 asset to fetch, and
|
|
# this image is linux-amd64. 1.9.37 is the newest release that has one. When the
|
|
# matrix publishes linux-amd64 again, this is one version + one checksum.
|
|
ARG HANZO_CLI_VERSION=1.9.45
|
|
ARG HANZO_CLI_SHA256_AMD64=4e04dfd99eb551215c8c484e87c704b67180b4d2c420558e30e26a251a1200d2
|
|
ARG HANZO_CLI_SHA256_ARM64=63523a2c015ba5490caf1909a5b927cc1944638fa368f70d07604647135e9f56
|
|
# 0.6.94 IS THE RELEASE THE OLD PIN WAS WAITING FOR.
|
|
#
|
|
# The note this replaces held dev at 0.6.91 and said: bump to the first release
|
|
# that contains create_hanzo_provider's alt_env_keys (HANZO_API_KEY,
|
|
# HANZO_MACHINE_TOKEN, tried before the human session), 0.6.94 or later, and not
|
|
# before. 0.6.94 is that release — bc20e6af40 is an ancestor of v0.6.94 and NOT
|
|
# of v0.6.93, so the condition is met exactly, not approximately.
|
|
#
|
|
# Why it mattered: 0.6.92 and 0.6.93 read HANZO_USER_KEY and nothing else, and no
|
|
# pod holds a human's login. A sandbox agent on either would have gone out
|
|
# silently unauthenticated. That is also why cloud minting a per-lease inference
|
|
# grant is only half the fix — the grant arrives on the machine variables, and
|
|
# 0.6.91 was pinned precisely because it happened to read one of them.
|
|
#
|
|
# Checksums verified against the bytes, not the .sha256 beside them: downloaded
|
|
# both tarballs and sha256sum'd them, and they agree with the published files.
|
|
ARG HANZO_DEV_VERSION=0.6.94
|
|
ARG HANZO_DEV_SHA256_AMD64=35f1539bd8a04e753fa213f9552f7bcc2a44350f2db0e32cdfb5b780fc0bb8f4
|
|
ARG HANZO_DEV_SHA256_ARM64=d2db0c22596f4d50270f65dd83a623d7c7677acab501d0bdac189152aabaa645
|
|
# hanzo-mcp comes from npm `@hanzo/mcp`, which is the canonical server. It ships
|
|
# bins `hanzo-mcp` and `mcp` from one package, so the name `dev` launches
|
|
# (mcp_servers.hanzo.command) resolves to it with no shim.
|
|
#
|
|
# It used to install hanzoai/mcp's `rust-vX.Y.Z` release asset. That lineage sits
|
|
# at 1.1.x while npm is at 2.4.x — the pods were running 1.1.15 — so the sandbox
|
|
# was several majors behind the server everything else speaks. npm verifies the
|
|
# tarball against the registry integrity hash, which is the same guarantee the
|
|
# sha256sum below gives the other two.
|
|
ARG HANZO_MCP_VERSION=2.4.4
|
|
RUN set -eux; \
|
|
case "${TARGETARCH}" in \
|
|
amd64) csha="${HANZO_CLI_SHA256_AMD64}"; dsha="${HANZO_DEV_SHA256_AMD64}" ;; \
|
|
arm64) csha="${HANZO_CLI_SHA256_ARM64}"; dsha="${HANZO_DEV_SHA256_ARM64}" ;; \
|
|
*) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
|
|
esac; \
|
|
for spec in \
|
|
"hanzoai/cli:v${HANZO_CLI_VERSION}:hanzo:${csha}" \
|
|
"hanzoai/dev:v${HANZO_DEV_VERSION}:dev:${dsha}" \
|
|
; do \
|
|
repo="${spec%%:*}"; rest="${spec#*:}"; \
|
|
tag="${rest%%:*}"; rest="${rest#*:}"; \
|
|
bin="${rest%%:*}"; sha="${rest#*:}"; \
|
|
curl -fsSLo /tmp/h.tar.gz \
|
|
"https://github.com/${repo}/releases/download/${tag}/${bin}-linux-${TARGETARCH}.tar.gz"; \
|
|
echo "${sha} /tmp/h.tar.gz" | sha256sum -c -; \
|
|
tar -xzf /tmp/h.tar.gz -C /tmp "${bin}"; \
|
|
install -m 0755 "/tmp/${bin}" "/usr/local/bin/${bin}"; \
|
|
rm -f /tmp/h.tar.gz "/tmp/${bin}"; \
|
|
done; \
|
|
npm install -g "@hanzo/mcp@${HANZO_MCP_VERSION}"; \
|
|
hanzo --version; dev --version; hanzo-mcp --version
|
|
|
|
# THE IMAGE SAYS ITS OWN VERSION, and it is the SAME file the tag comes from.
|
|
# docker/sandbox.version is read twice — by hanzoai/ci's imgver, which turns it
|
|
# into the published `<version>-<class>` tag, and by this COPY, which puts it
|
|
# where a running pod can be asked. One number, one file, two readers; there is
|
|
# no second place for the tag and the contents to disagree.
|
|
#
|
|
# WHY THE IMAGE NEEDED ITS OWN NUMBER AT ALL. It used to inherit this repo's
|
|
# package.json — bot's version, 2026.6.7, last bumped 2026-06-07 — so a rebuild
|
|
# on 2026-08-06 republished `2026.6.7-desktop` from source two months newer.
|
|
# Measured: `2026.6.7-desktop` and `sha-d190451-amd64-desktop` are one digest,
|
|
# sha256:7a41f69f…. A tag that gets rewritten is not a pin, and one that LOOKS
|
|
# pinned is worse than `latest`, which at least admits what it is. This number
|
|
# belongs to the IMAGE and moves when the image does — so 1.0.0 is a fresh
|
|
# series, deliberately not a continuation of a number that was never ours. The
|
|
# old 2026.6.7-* tags stay in the registry as archaeology.
|
|
COPY docker/sandbox.version /etc/sandbox-version
|
|
|
|
USER sandbox
|
|
|
|
# ---- The shell's config ----------------------------------------------------
|
|
# ellipsis.sh, and three packages it installs from their own repos: zsh (zeesh),
|
|
# vim and files. At BUILD time and as the user who reads them, so a fresh pod
|
|
# opens on a configured shell and fetches nothing at the first prompt.
|
|
#
|
|
# `ellipsis install zeekay/zsh` resolves to https://github.com/zeekay/dot-zsh —
|
|
# the `dot-` is ELLIPSIS_PREFIX and https is ELLIPSIS_PROTO, both its defaults,
|
|
# so the three names below are the names the packages are known by.
|
|
#
|
|
# THE PLATFORM IS THE PACKAGE'S CHOICE, NOT OURS. dot-zsh's pkg.link switches on
|
|
# os.platform() and links platform/linux/zshrc where a Mac gets platform/osx.
|
|
# The assert names the linux path: a package that quietly linked the other one
|
|
# is a shell that half works, and no prompt would say so.
|
|
#
|
|
# ~/.zshenv SETS no_global_rcs, which is why nothing is installed in /etc/zsh —
|
|
# zsh skips the global rc files entirely once it reads that. It also stops
|
|
# `zsh -l` from sourcing /etc/profile back over the PATH set above.
|
|
#
|
|
# ~/.zshenv also sources $CARGO_HOME/env. CARGO_HOME moved to the home directory
|
|
# for a writable cache while rustup wrote its env file beside the toolchain, so
|
|
# one symlink makes the file rustup wrote the file the shell reads.
|
|
#
|
|
# A LINK WHOSE TARGET THIS IMAGE DOES NOT CARRY IS NOT CONFIGURATION. dot-files
|
|
# points several ~/.config entries — hanzo, gh, gcloud, hub — at a sibling
|
|
# package it installs only on its author's machine, and `mkdir` on a dangling
|
|
# link fails EEXIST rather than creating anything. A tool that makes its own
|
|
# config directory on first use therefore cannot start: `hanzo auth login` is the
|
|
# one that matters here, because a sandbox is handed its owner's session that
|
|
# way. The links that RESOLVE are the configuration and they stay; the rest are
|
|
# deleted, and the assert keeps the next package from adding more.
|
|
#
|
|
# ellipsis runs its hooks without set -e, so `install` exiting 0 is not evidence.
|
|
# The asserts are: one link per package, and a completion dump that only exists
|
|
# if an interactive shell actually READ the rc — which also builds the dump into
|
|
# this layer, so the first prompt in a pod does not pay to compile one.
|
|
RUN set -eux; \
|
|
ln -s /usr/local/cargo/env "${HOME}/.cargo/env"; \
|
|
[ -f "${HOME}/.cargo/env" ]; \
|
|
git clone --depth 1 https://github.com/ellipsis/ellipsis "${HOME}/.ellipsis"; \
|
|
"${HOME}/.ellipsis/bin/ellipsis" install zeekay/zsh zeekay/vim zeekay/files; \
|
|
find "${HOME}" -xtype l -delete; \
|
|
[ -z "$(find "${HOME}" -xtype l)" ]; \
|
|
[ "$(readlink -f "${HOME}/.zshrc")" = "${HOME}/.ellipsis/packages/zsh/platform/linux/zshrc" ]; \
|
|
[ -f "${HOME}/.vimrc" ]; \
|
|
[ -f "${HOME}/.tmux.conf" ]; \
|
|
zsh -ic true; \
|
|
[ -s "${HOME}/.zsh/cache/zcompdump" ]
|
|
|
|
# The agent is on PATH for the user that will actually run it, and `hanzo` is
|
|
# not `dev`. Checked HERE, in the default class, because this is the class a
|
|
# caller gets without asking — the `dev` stage repeats it over its own larger
|
|
# surface. The symlink check is the specific regression it catches: installing
|
|
# the agent from npm put one dev.js at both names, so the cloud CLI silently
|
|
# became the coding agent and no `hanzo` command existed at all.
|
|
RUN set -eux; \
|
|
hanzo --version; dev --version; hanzo-mcp --version; \
|
|
[ "$(readlink -f "$(command -v hanzo)")" != "$(readlink -f "$(command -v dev)")" ] \
|
|
|| { echo "hanzo and dev are the same file again" >&2; exit 1; }
|
|
|
|
WORKDIR /work
|
|
|
|
# tini reaps. A sandbox is a long-lived PID 1 whose children are compilers,
|
|
# language servers and test runners started by `kubectl exec`; without a reaper
|
|
# their zombies accumulate for the life of the pod.
|
|
#
|
|
# IT ONLY REAPS IF IT IS PID 1, and that is decided OUTSIDE this file: a
|
|
# Kubernetes container `command:` replaces ENTRYPOINT *and* CMD together, so a
|
|
# pod spec that names `sleep infinity` gets sleep as PID 1 and no reaper at all.
|
|
# hanzoai/cloud apps/sandbox/runtime.go therefore names neither, and lets the
|
|
# image below say what each class runs. Measured before that change: `cat
|
|
# /proc/1/cmdline` in a live desktop pod answered `sleep infinity`.
|
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
|
|
|
# `sleep infinity`, and this is the whole design. The pod is the sandbox; every
|
|
# command arrives through the Kubernetes exec subresource. Nothing in here
|
|
# listens, so there is no in-pod surface to authenticate, rate-limit or forget
|
|
# to authenticate.
|
|
CMD ["sleep", "infinity"]
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# dev — exec plus the THIRD-PARTY agents, and gh to propose the work.
|
|
#
|
|
# Our own agent is not here; it is in `exec`, because `exec` is what a caller
|
|
# gets by default and a default sandbox with no agent is the whole defect this
|
|
# stage boundary used to cause. What is left here is the 246 MB that is somebody
|
|
# else's product — claude and codex — which a caller asks for by naming `dev`.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
FROM exec AS dev
|
|
|
|
USER root
|
|
|
|
# Re-declared: an ARG is scoped to the stage that declares it, and `exec`'s
|
|
# TARGETARCH does not survive into this one. Without this line ${TARGETARCH}
|
|
# below expands to the empty string and the gh download 404s.
|
|
ARG TARGETARCH
|
|
|
|
# Pinned, never floating. npm dist-tags.latest as of 2026-08-06.
|
|
ARG GH_VERSION=2.97.0
|
|
|
|
# THE TWO npm AGENT CLIs ARE ONE INSTALL BECAUSE `tool` IS ONE FIELD.
|
|
# A run names its tool — dev | claude | codex | python | node — and the runner
|
|
# turns that name into an argv. `python` and `node` are already here from the
|
|
# exec stage; `dev` is the native binary below. If `claude` and `codex` were not
|
|
# here, the enum would be values that work and values that answer "command not
|
|
# found" at the far end of a scheduled pod, which is a worse failure than not
|
|
# offering them: the caller gets a run that starts, burns its timeout and
|
|
# produces nothing. These two are npm products; ours are not.
|
|
ARG CLAUDE_VERSION=2.1.223
|
|
ARG CODEX_VERSION=0.146.1
|
|
|
|
# gh, for the forge and for GitHub. `git` alone cannot open a PR, and an agent
|
|
# that can commit but not propose is half a workflow.
|
|
RUN set -eux; \
|
|
curl -fsSLo /tmp/gh.tar.gz "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${TARGETARCH}.tar.gz"; \
|
|
tar -xzf /tmp/gh.tar.gz -C /tmp; \
|
|
install -m 0755 "/tmp/gh_${GH_VERSION}_linux_${TARGETARCH}/bin/gh" /usr/local/bin/gh; \
|
|
rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_${TARGETARCH}"; \
|
|
gh --version | head -1
|
|
|
|
USER sandbox
|
|
|
|
# THEY BELONG TO THE USER THAT RUNS THEM, and that is what makes them
|
|
# updatable. Installed root-owned under /usr/local/lib/node_modules, claude
|
|
# greets every shell with "Auto-update failed: no write permission to npm
|
|
# prefix" and then stays on the version baked into the layer for the life of the
|
|
# image. The prefix here is the user's own ~/.local, which PATH puts ahead of
|
|
# /usr/local/bin: one claude on the box, uid 1000 owns it, and both its own
|
|
# updater and `claude install latest` write beside it rather than failing.
|
|
RUN set -eux; \
|
|
npm config set prefix /home/sandbox/.local; \
|
|
npm install -g \
|
|
"@anthropic-ai/claude-code@${CLAUDE_VERSION}" \
|
|
"@openai/codex@${CODEX_VERSION}"; \
|
|
npm cache clean --force
|
|
|
|
# Prove the toolchain is on PATH and executable, as the user that will run it.
|
|
# A `RUN` that fails here fails the BUILD, which is the only place this can be
|
|
# caught before a pod is scheduled and an agent finds no compiler.
|
|
#
|
|
# `claude` RESOLVING TO THE USER'S COPY is asserted by path, not by version,
|
|
# because that is the fact the version cannot report: a root-owned copy on an
|
|
# earlier PATH entry answers `--version` exactly as happily and then cannot
|
|
# update itself. Both halves — the prefix and the PATH order — have to hold for
|
|
# this line to pass, and neither is visible from the other's file.
|
|
RUN set -eux; \
|
|
hanzo --version; dev --version; hanzo-mcp --version; \
|
|
claude --version; codex --version; \
|
|
[ "$(command -v claude)" = "${HOME}/.local/bin/claude" ]; \
|
|
node --version; npm --version; pnpm --version; bun --version; \
|
|
deno --version; python3 --version; uv --version; go version; rustc --version; \
|
|
cargo --version; zig version; java --version; ruby --version; php --version | head -1; \
|
|
git --version; rg --version | head -1; jq --version; gh --version | head -1; \
|
|
chromium --version; \
|
|
node -e 'require("playwright")' && echo "playwright: resolvable as sandbox"; \
|
|
[ "$(readlink -f "$(command -v hanzo)")" != "$(readlink -f "$(command -v dev)")" ] \
|
|
|| { echo "hanzo and dev are the same file again" >&2; exit 1; }
|
|
|
|
# ---- The agent gets tools, not a shell and a hope --------------------------
|
|
# Both binaries were already here and neither knew about the other. `dev` ran
|
|
# with its own built-ins; `hanzo-mcp` — fetch, browser, git, exec, fs, search —
|
|
# sat on PATH and nothing ever launched it. Measured in a live pod before this:
|
|
# `dev mcp list` said "No MCP servers configured yet."
|
|
#
|
|
# One line of config is the whole fix, and it belongs HERE rather than in an
|
|
# orchestrator. A run that has to be told where its tools are is a run that has
|
|
# them only when the caller remembers; putting it in the image means every
|
|
# sandbox of this class has them, on both execution paths, with no code in
|
|
# either. The dev and desktop classes carry both binaries, so this is exactly
|
|
# the layer where the fact is true.
|
|
#
|
|
# `dev mcp add` AND NOT A HAND-WRITTEN FILE. The store is dev's, so dev names
|
|
# it: the CLI's help text advertises `~/.code/config.toml` and it actually
|
|
# writes `~/.hanzo/dev/config.toml` — a file written to the documented path is
|
|
# silently ignored, which is how this looked configured while being empty. Ask
|
|
# the tool and the path cannot be wrong or go stale under a rename.
|
|
#
|
|
# STDIO, because that is what the server speaks. hanzo-mcp 1.1.15 is JSON-RPC
|
|
# over stdin/stdout — it binds no port and listens on nothing, so the tool
|
|
# channel is a pipe between two processes in one pod and there is nothing on it
|
|
# to reach from outside. The pod's one channel out stays the Kubernetes exec
|
|
# stream. (The 1.1.12 source still says HTTP on 127.0.0.1; the shipped binary
|
|
# is stdio, and the binary is what runs.)
|
|
#
|
|
# THE CHECK IS `dev` STARTING THE SERVER, not a handshake we write ourselves.
|
|
# The previous version of this block sent its own initialize with
|
|
# protocolVersion "2024-11-05" — the revision the server happens to answer — and
|
|
# asserted tools came back. It could not fail. It passed on an image where `dev`
|
|
# refused the server outright: dev asks for 2025-06-18, hanzo-mcp replied
|
|
# 2024-11-05 to every request whatever was asked, and dev reported `MCP server
|
|
# hanzo failed to start`. Registered, on PATH, 29 tools to a hand probe, and
|
|
# unusable by the only thing that was ever going to use it.
|
|
#
|
|
# A test that picks both sides of a negotiation tests neither. The real check —
|
|
# running the agent and reading what the AGENT says about the server — lives in
|
|
# the probe below, together with the provider assertion, because both facts come
|
|
# from one `dev` run and splitting them would run the agent twice to learn two
|
|
# halves of one answer. Registering is all that happens here.
|
|
RUN set -eux; \
|
|
dev mcp add hanzo -- hanzo-mcp; \
|
|
dev mcp list | grep -q hanzo-mcp \
|
|
|| { echo "dev does not see hanzo-mcp after adding it" >&2; exit 1; }
|
|
|
|
# ---- The box asks OUR gateway ----------------------------------------------
|
|
# Out of the box `dev` reports `provider: openai, model: gpt-5.3-codex`: a
|
|
# sandbox we ship, running our agent, sending our customers' code to somebody
|
|
# else's endpoint by default. api.hanzo.ai is the one door, so the box names it.
|
|
#
|
|
# NO CREDENTIAL IS BAKED, and none can be. What is written here is the NAME of
|
|
# the variable to read — `env_key` — so the key arrives at run time on the
|
|
# process env and never exists in a layer. An image is a public artifact; a
|
|
# secret in one is published, not configured.
|
|
#
|
|
# `dev mcp add` wrote the MCP entry to this same store, so this appends rather
|
|
# than replaces: the file is dev's and both facts belong in it.
|
|
# The store is found by its CONTENT, not by a path spelled out here. `dev mcp
|
|
# add` has just written the `[mcp_servers.hanzo]` block, so the file holding it
|
|
# is dev's real config wherever dev decided that is — the CLI advertises
|
|
# `~/.code/config.toml` in its help and writes `~/.hanzo/dev/config.toml`, and a
|
|
# path pinned here would survive exactly until one of those changed and then
|
|
# append to a file nothing reads.
|
|
#
|
|
# ORDER IS LOAD-BEARING, and getting it wrong is silent. `model_provider` and
|
|
# `model` are TOP-LEVEL keys; in TOML a bare key written after a `[table]`
|
|
# header belongs to THAT TABLE. Appending them to the end of a file that already
|
|
# contains `[mcp_servers.hanzo]` makes them `mcp_servers.hanzo.model_provider`,
|
|
# which nothing reads — the file looks exactly right and `dev` still reports
|
|
# `provider: openai`. So the bare keys go first and every table after them.
|
|
#
|
|
# The assertion is dev's OWN report, not a grep of the file we just wrote:
|
|
# reading back your own write proves the disk works, not that the program
|
|
# agrees with you.
|
|
#
|
|
# `--skip-git-repo-check`, AND WITHOUT IT THIS PROBE TESTS NOTHING. `dev exec`
|
|
# refuses to run outside a git repo — "Not inside a trusted directory" — and
|
|
# exits before it prints a banner or starts a single MCP server. WORKDIR here is
|
|
# /work, which is empty. A probe that only greps for a FAILURE string therefore
|
|
# passes on that refusal, having tested nothing at all; that is the same shape of
|
|
# can't-fail check as the hand-written handshake this replaced. So the provider
|
|
# line is asserted POSITIVELY first: if dev did not get far enough to name a
|
|
# provider, nothing after it was exercised and the build says so.
|
|
RUN set -eux; \
|
|
cfg="$(grep -rl '^\[mcp_servers\.hanzo\]' "${HOME}" --include=config.toml 2>/dev/null | head -1)"; \
|
|
[ -n "$cfg" ] || { echo "cannot find the config dev mcp add just wrote" >&2; exit 1; }; \
|
|
tmp="$(mktemp)"; \
|
|
{ \
|
|
echo 'model_provider = "hanzo"'; \
|
|
echo 'model = "zen5-coder"'; \
|
|
echo ''; \
|
|
cat "$cfg"; \
|
|
echo ''; \
|
|
echo '[model_providers.hanzo]'; \
|
|
echo 'name = "Hanzo"'; \
|
|
echo 'base_url = "https://api.hanzo.ai/v1"'; \
|
|
echo 'env_key = "HANZO_API_KEY"'; \
|
|
} > "$tmp"; \
|
|
mv "$tmp" "$cfg"; \
|
|
timeout 120 dev exec --skip-git-repo-check "say ok" > /tmp/probe.out 2>&1 || true; \
|
|
grep -q '^provider: hanzo' /tmp/probe.out \
|
|
|| { echo "dev did not get far enough to name a provider, so nothing below was tested:" >&2; \
|
|
head -20 /tmp/probe.out >&2; exit 1; }; \
|
|
grep -q '^model: zen5-coder' /tmp/probe.out \
|
|
|| { echo "dev names a model that is not ours:" >&2; grep '^model:' /tmp/probe.out >&2; exit 1; }; \
|
|
if grep -q 'MCP server .*failed to start' /tmp/probe.out; then \
|
|
echo "dev cannot start hanzo-mcp — the agent and the server disagree:" >&2; \
|
|
grep 'MCP server .*failed to start' /tmp/probe.out >&2; \
|
|
exit 1; \
|
|
fi; \
|
|
rm -f /tmp/probe.out
|
|
|
|
WORKDIR /work
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# desktop — dev plus a virtual display it can see and click.
|
|
#
|
|
# LAYERED ON dev, NOT BESIDE IT. This repo's older `sandbox-browser` recipe was
|
|
# a sibling of `sandbox`, which gives a computer-use pod a browser and no
|
|
# toolchain — it can drive a page but not edit the code that serves it.
|
|
#
|
|
# WHAT IS CARRIED OVER FROM hanzoai/operative, AND WHAT IS NOT.
|
|
#
|
|
# Carried: the X/VNC recipe and, specifically, its ONE hard-won bug fix.
|
|
# operative's docker/image/.operative/x11vnc_startup.sh says it in full —
|
|
#
|
|
# x11vnc (LibVNCServer 0.9.13) has a MSG_PEEK deadlock bug: on new
|
|
# connections it peeks at the first byte to detect WebSocket vs RFB, but in
|
|
# RFB the server speaks first, so both sides wait forever.
|
|
#
|
|
# — and switches to TigerVNC's x0vncserver. That fix is the reason to read
|
|
# operative's work rather than start over, and it is why the entrypoint below
|
|
# runs x0vncserver and not x11vnc.
|
|
#
|
|
# NOT carried: operative's docker/Dockerfile.xvfb itself. Layering on it would
|
|
# import mongodb-org, postgresql, mariadb-server and redis-server (three
|
|
# storage directives broken at once), docker.io + colima (a container runtime
|
|
# INSIDE the thing that is meant to BE the containment boundary), plus VS Code,
|
|
# Emacs, LibreOffice, .NET, Julia, Dart and Scala — and it never drops root.
|
|
# That image stays where it is for operative's own work; this is the same X
|
|
# stack without the workstation.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
FROM dev AS desktop
|
|
|
|
USER root
|
|
|
|
RUN apt-get update \
|
|
&& apt-get install -y --no-install-recommends \
|
|
dbus-x11 \
|
|
fonts-dejavu-core \
|
|
fonts-liberation \
|
|
fonts-noto-color-emoji \
|
|
hsetroot \
|
|
novnc \
|
|
openbox \
|
|
scrot \
|
|
tigervnc-common \
|
|
tigervnc-scraping-server \
|
|
websockify \
|
|
x11-utils \
|
|
x11-xserver-utils \
|
|
xauth \
|
|
xdotool \
|
|
xterm \
|
|
xvfb \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# THE SCREEN NEEDS SOMETHING THAT DRAWS, and these two are it. Xvfb and openbox
|
|
# together produce a managed root window with nothing on it, which is a working
|
|
# desktop and an unusable one: openbox's own root menu offers a terminal through
|
|
# the x-terminal-emulator alternative, and until something registers that
|
|
# alternative the entry runs nothing.
|
|
#
|
|
# xterm registers it, and is the terminal the screen opens. It draws
|
|
# through plain X11, so it renders on a software Xvfb where a
|
|
# GPU-accelerated terminal has no GL context to ask for.
|
|
# hsetroot paints the root window. The alternative in x11-xserver-utils,
|
|
# xsetroot, paints one flat colour; a gradient is the whole
|
|
# difference between a background and an unlit screen.
|
|
#
|
|
# Onyx is openbox's own dark theme, shipped in the openbox package, so the
|
|
# window decorations match what is behind them without carrying a theme of our
|
|
# own. It is set by EDITING the system rc.xml, while the menu is REPLACED — the
|
|
# two configs differ in kind. A menu is data with no compiled-in default to lose,
|
|
# but a partial rc.xml drops openbox back to its built-in bindings for every
|
|
# element it omits, which is a desktop with no alt-tab.
|
|
#
|
|
# The stock menu names x-www-browser, obconf and a /Debian submenu built from a
|
|
# file no minimal install carries, so three of its entries run nothing and
|
|
# openbox logs a missing-menu error at every boot. Ours names the two programs
|
|
# that are here, and the asserts below are what keep that true.
|
|
COPY docker/desktop/Xresources /etc/X11/Xresources/sandbox
|
|
COPY docker/desktop/menu.xml /etc/xdg/openbox/menu.xml
|
|
RUN set -eux; \
|
|
sed -i 's|<name>Clearlooks</name>|<name>Onyx</name>|' /etc/xdg/openbox/rc.xml; \
|
|
grep -q '<name>Onyx</name>' /etc/xdg/openbox/rc.xml; \
|
|
[ -d /usr/share/themes/Onyx/openbox-3 ]; \
|
|
for p in $(sed -n 's|.*<execute>\(.*\)</execute>.*|\1|p' /etc/xdg/openbox/menu.xml); do command -v "$p"; done
|
|
|
|
# THE VNC SERVER IS X0tigervnc, AND IT IS ALREADY HERE — there is no x11vnc in
|
|
# this image and there must not be one. `tigervnc-scraping-server` above is the
|
|
# package that carries it, and also carries /usr/bin/x0vncserver, which is NOT
|
|
# the server but the session manager that starts it. The script calls the server;
|
|
# see the reason written where it is called. Reading `command -v x11vnc` -> empty
|
|
# and concluding the port EXPOSEd below is unserved is the natural mistake, and
|
|
# it costs an afternoon; this line asserts what actually serves the screen, at
|
|
# build time, so the mistake is answered by the build instead of by a person.
|
|
#
|
|
# socat is asserted with them because the screen is READ over it: cloud runs
|
|
# `socat - TCP:127.0.0.1:5900` through the exec channel and carries RFB out to a
|
|
# browser on that stream. It is installed at the bottom of the chain for every
|
|
# class, so a package list somebody trims one day would take the desktop's only
|
|
# way out with it and nothing else in this image would notice.
|
|
RUN set -eux; for b in Xvfb openbox X0tigervnc socat xdpyinfo xterm hsetroot xrdb; do command -v "$b"; done
|
|
|
|
# THE BROWSER IS NOT INSTALLED HERE — it is in `exec`, at the bottom of the
|
|
# chain, so `dev` has one too. What this stage adds is a SCREEN for it: with
|
|
# DISPLAY set, the same chromium launches headful into a real window that can be
|
|
# clicked, screenshotted and watched over VNC. Headless and headful are one
|
|
# browser and one install; the difference is whether an X server exists.
|
|
COPY scripts/sandbox-desktop.sh /usr/local/bin/sandbox-desktop
|
|
RUN chmod 0755 /usr/local/bin/sandbox-desktop
|
|
|
|
USER sandbox
|
|
WORKDIR /work
|
|
|
|
ENV DISPLAY=:1 \
|
|
SANDBOX_SCREEN=1280x800x24
|
|
|
|
# 5900 RFB · 6080 noVNC. Bound to loopback unless the entrypoint is told
|
|
# otherwise — see the script.
|
|
EXPOSE 5900 6080
|
|
|
|
# Starts the display, then becomes `sleep infinity`. The X server, the window
|
|
# manager and the VNC bridge are a SCREEN, not a control plane: none of them
|
|
# accepts a command that runs code, and work still arrives only through the
|
|
# Kubernetes exec subresource, exactly as it does for the other two classes.
|
|
CMD ["sandbox-desktop"]
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# android — desktop, plus a phone drawn on its screen.
|
|
#
|
|
# THIS ADDS NO WAY TO LOOK AT ANYTHING, and that is the whole design. An Android
|
|
# emulator is qemu with an X window; the display it draws on is the one `desktop`
|
|
# already starts and hanzoai/cloud already serves. So the class is an SDK, an AVD
|
|
# and one more argument to sandbox-desktop — not a stream, not a port, not a
|
|
# client. Anything here that ended up looking like a second way to watch a screen
|
|
# would be a bug wearing a feature's clothes.
|
|
#
|
|
# IT NEEDS /dev/kvm AND THAT IS A SCHEDULING FACT, NOT A BUILD ONE. qemu without
|
|
# KVM emulates the guest CPU instruction by instruction, and a cold Android 15
|
|
# boot never finishes in a time anyone waits through. The device reaches a pod
|
|
# through the KVM device plugin (hanzoai/universe infra/k8s/kvm), the pod asks
|
|
# for it by resource name, and `avd` refuses at startup where it is absent —
|
|
# because the alternative is a phone that boots forever and looks busy.
|
|
#
|
|
# amd64 ONLY, and for once that is the image's own limit rather than the fleet's:
|
|
# Google publishes no linux-aarch64 emulator. The arm64 answer is Cuttlefish,
|
|
# which is a different program and would be a different stage.
|
|
#
|
|
# THE SDK IS PINNED WHERE PINNING EXISTS AND NOT WHERE IT DOES NOT, said plainly
|
|
# rather than papered over. commandlinetools is a zip at a URL and carries a
|
|
# sha256 like every other third-party input in this file. The packages
|
|
# sdkmanager then installs are named `emulator`, `platform-tools` and
|
|
# `system-images;android-35;google_apis;x86_64` — a package PATH has no revision
|
|
# field, so those float on Google's stable channel and two builds a month apart
|
|
# can differ. That is a real gap in this file's reproducibility rule, and the
|
|
# honest fix is not a comment claiming otherwise: it is the digest hanzoai/cloud
|
|
# already pins the running image by (SANDBOX_IMAGE_DIGEST_ANDROID), which names
|
|
# bytes no matter what produced them.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
FROM desktop AS android
|
|
|
|
USER root
|
|
|
|
ENV ANDROID_HOME=/opt/android \
|
|
ANDROID_SDK_ROOT=/opt/android \
|
|
ANDROID_AVD_HOME=/opt/android/avd
|
|
|
|
# What qemu and the emulator's own UI link against. The JDK the SDK tools need is
|
|
# already at the bottom of the chain (openjdk-25, `exec` stage), and so is every
|
|
# X library the desktop stage brought.
|
|
RUN apt-get update \
|
|
&& apt-get install -y --no-install-recommends \
|
|
libnss3 \
|
|
libpulse0 \
|
|
libxcursor1 \
|
|
libxdamage1 \
|
|
libxi6 \
|
|
libxrandr2 \
|
|
libxtst6 \
|
|
mesa-utils \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# 13114758 is the current stable commandlinetools build; the sha256 is of the
|
|
# published zip, read from the bytes this image was proven against.
|
|
ARG ANDROID_TOOLS=commandlinetools-linux-13114758_latest.zip
|
|
ARG ANDROID_TOOLS_SHA256=7ec965280a073311c339e571cd5de778b9975026cfcbe79f2b1cdcb1e15317ee
|
|
RUN set -eux; \
|
|
curl -fsSL -o /tmp/tools.zip "https://dl.google.com/android/repository/${ANDROID_TOOLS}"; \
|
|
echo "${ANDROID_TOOLS_SHA256} /tmp/tools.zip" | sha256sum -c -; \
|
|
mkdir -p "${ANDROID_HOME}/cmdline-tools"; \
|
|
unzip -q /tmp/tools.zip -d /tmp/clt; \
|
|
mv /tmp/clt/cmdline-tools "${ANDROID_HOME}/cmdline-tools/latest"; \
|
|
rm -rf /tmp/tools.zip /tmp/clt
|
|
|
|
ENV PATH=/opt/android/cmdline-tools/latest/bin:/opt/android/platform-tools:/opt/android/emulator:$PATH
|
|
|
|
# The AVD is created HERE and not on first boot. Creating it costs a few seconds
|
|
# and writing it into the layer is what makes a lease fast; doing it at startup
|
|
# would also mean every pod's first boot is the one that discovers the SDK is
|
|
# incomplete, in a place nobody is reading the log.
|
|
RUN set -eux; \
|
|
yes | sdkmanager --licenses >/dev/null; \
|
|
sdkmanager --install \
|
|
"platform-tools" \
|
|
"emulator" \
|
|
"platforms;android-35" \
|
|
"system-images;android-35;google_apis;x86_64"; \
|
|
mkdir -p "${ANDROID_AVD_HOME}"; \
|
|
echo no | avdmanager create avd -n hanzo \
|
|
-k "system-images;android-35;google_apis;x86_64" -d pixel_6 --force; \
|
|
chown -R sandbox:sandbox "${ANDROID_HOME}"
|
|
|
|
COPY scripts/avd.sh /usr/local/bin/avd
|
|
RUN chmod 0755 /usr/local/bin/avd
|
|
|
|
# Assert the four things a phone needs, at build time, for the same reason the
|
|
# desktop stage asserts its five: each of them fails at runtime as a pod that
|
|
# looks perfectly healthy and shows an empty screen.
|
|
RUN set -eux; \
|
|
for b in avd emulator adb avdmanager; do command -v "$b"; done; \
|
|
avdmanager list avd | grep -q 'Name: hanzo'
|
|
|
|
USER sandbox
|
|
WORKDIR /work
|
|
|
|
# The screen, then the phone. One argument, and everything the desktop class
|
|
# does still happens exactly once.
|
|
CMD ["sandbox-desktop", "avd"]
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# admin — the operator's box. dev, plus the two clients that talk to the fleet
|
|
# and a shell worth sitting in.
|
|
#
|
|
# LAYERED ON dev AND NOT ON desktop, so `dev` is the one class it substitutes
|
|
# for. That is not a preference: hanzoai/cloud swaps this image in for a
|
|
# SuperAdmin's `dev` sandbox and leaves `exec` and `desktop` alone, and a
|
|
# substitute has to be a superset of the thing it replaces or the swap changes
|
|
# what the class MEANS. exec stays the throwaway; desktop keeps its screen.
|
|
#
|
|
# IT SHIPS THE CLIENTS AND NOT THE CREDENTIALS, and that is the property that
|
|
# makes the rest of this simple. kubectl with no kubeconfig and doctl with no
|
|
# token reach nothing at all — they are argument parsers until something hands
|
|
# the POD a credential, and what a pod is handed is decided at runtime by the
|
|
# identity that leased it. So this image confers no authority over anything, and
|
|
# "who may pull it" is a question about bandwidth rather than about access.
|
|
#
|
|
# THAT INVARIANT IS LOAD-BEARING AND MUST NOT BE TRADED AWAY. A kubeconfig or a
|
|
# DO token baked in here would be a credential published to a registry, readable
|
|
# by anyone who can pull one layer, and it would move the boundary from an
|
|
# identity check to an image name — where there is no identity to check.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
FROM dev AS admin
|
|
|
|
USER root
|
|
|
|
# Re-declared: an ARG is scoped to the stage that declares it, the same reason
|
|
# `dev` re-declares it for the gh download two stages up.
|
|
ARG TARGETARCH
|
|
|
|
# kubectl ON THE 1.35 LINE, WHICH IS NOT `stable`. kubectl is supported within
|
|
# one minor of the apiserver it talks to, and the fleet runs 1.34 — measured on
|
|
# do-sfo3-hanzo-k8s: v1.34.1. Today's `stable.txt` says 1.36, which is two
|
|
# minors out and would be unsupported against every cluster this box exists to
|
|
# reach. `stable.txt` is also a moving name, and this file resolves no moving
|
|
# names by design. When DOKS moves, this is one version and two checksums.
|
|
ARG KUBECTL_VERSION=1.35.7
|
|
ARG KUBECTL_SHA256_AMD64=12e97f9d23a9f6cbb87b89becd6bd291e1a858a3379a4e11e2c822c4c1530052
|
|
ARG KUBECTL_SHA256_ARM64=5103d45b8881434d417694057b8ccb5ae79fd310a5c5c13e403c5e62e15909be
|
|
|
|
# doctl, for the parts of the fleet that are not in a cluster — registries,
|
|
# databases, spaces, the load balancers in front of all three.
|
|
ARG DOCTL_VERSION=1.166.0
|
|
ARG DOCTL_SHA256_AMD64=1596424b64091a7939bde561daf2402ca8a141966429928e688d20d17091ec89
|
|
ARG DOCTL_SHA256_ARM64=a9857d2ef4498f6affb9baae2b37b890ccc4ddda9ec01e58b63a2e99e47eb363
|
|
|
|
# Two publishers, two shapes, one rule: verify the bytes before running them.
|
|
# kubectl is a bare binary with its checksum published beside it; doctl is a
|
|
# tarball. Neither checksum is fetched from the host that serves the artifact —
|
|
# that would prove the transport and not the bytes we agreed to ship. Both were
|
|
# read from the publisher on 2026-08-07.
|
|
RUN set -eux; \
|
|
case "${TARGETARCH}" in \
|
|
amd64) ksha="${KUBECTL_SHA256_AMD64}"; dsha="${DOCTL_SHA256_AMD64}" ;; \
|
|
arm64) ksha="${KUBECTL_SHA256_ARM64}"; dsha="${DOCTL_SHA256_ARM64}" ;; \
|
|
*) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
|
|
esac; \
|
|
curl -fsSLo /tmp/kubectl \
|
|
"https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl"; \
|
|
echo "${ksha} /tmp/kubectl" | sha256sum -c -; \
|
|
install -m 0755 /tmp/kubectl /usr/local/bin/kubectl; \
|
|
curl -fsSLo /tmp/doctl.tar.gz \
|
|
"https://github.com/digitalocean/doctl/releases/download/v${DOCTL_VERSION}/doctl-${DOCTL_VERSION}-linux-${TARGETARCH}.tar.gz"; \
|
|
echo "${dsha} /tmp/doctl.tar.gz" | sha256sum -c -; \
|
|
tar -xzf /tmp/doctl.tar.gz -C /tmp doctl; \
|
|
install -m 0755 /tmp/doctl /usr/local/bin/doctl; \
|
|
rm -f /tmp/kubectl /tmp/doctl /tmp/doctl.tar.gz; \
|
|
kubectl version --client=true; doctl version
|
|
|
|
USER sandbox
|
|
|
|
# Prove it, as the user that will run it. The last line is the one that matters:
|
|
# it asserts the rc was actually READ, not merely that zsh runs — an rc with a
|
|
# syntax error is a file zsh skips with a warning nobody sees until every
|
|
# terminal in the fleet has no history and no completion. The dump is written by
|
|
# the rc's own compinit, so its presence is the reading, and it carries down
|
|
# from `exec` where the dotfiles were installed.
|
|
RUN set -eux; \
|
|
for b in zsh tmux kubectl doctl hanzo dev hanzo-mcp gh git; do command -v "$b"; done; \
|
|
[ -s "${HOME}/.zsh/cache/zcompdump" ]
|
|
|
|
WORKDIR /work
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# The selector. Last stage, so it is what `docker build` produces by default;
|
|
# `FROM ${STAGE}` is an alias, so the published image IS the named stage —
|
|
# same layers, same config, no extra layer.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
FROM ${STAGE} AS final
|