Files
bot/Dockerfile.sandbox

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